feat: PWA Support (#1488)

pull/1463/head^2
sct 3 years ago committed by GitHub
parent e6e5ad221a
commit 28830d4ef8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,60 @@
name: Overseerr Preview
on:
push:
tags:
- 'preview-*'
jobs:
build_and_push:
name: Build & Publish Docker Preview Images
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Get the version
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2.1.5
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
build-args: |
COMMIT_TAG=${{ github.sha }}
tags: |
sctx/overseerr:${{ steps.get_version.outputs.VERSION }}
ghcr.io/sct/overseerr:${{ steps.get_version.outputs.VERSION }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- # Temporary fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

@ -1128,6 +1128,15 @@ components:
properties: properties:
webhookUrl: webhookUrl:
type: string type: string
WebPushSettings:
type: object
properties:
enabled:
type: boolean
example: false
types:
type: number
example: 2
WebhookSettings: WebhookSettings:
type: object type: object
properties: properties:
@ -2581,6 +2590,52 @@ paths:
responses: responses:
'204': '204':
description: Test notification attempted description: Test notification attempted
/settings/notifications/webpush:
get:
summary: Get Web Push notification settings
description: Returns current Web Push notification settings in a JSON object.
tags:
- settings
responses:
'200':
description: Returned web push settings
content:
application/json:
schema:
$ref: '#/components/schemas/WebPushSettings'
post:
summary: Update Web Push notification settings
description: Updates Web Push notification settings with the provided values.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebPushSettings'
responses:
'200':
description: 'Values were sucessfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/WebPushSettings'
/settings/notifications/webpush/test:
post:
summary: Test Web Push settings
description: Sends a test notification to the Web Push agent.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebPushSettings'
responses:
'204':
description: Test notification attempted
/settings/notifications/webhook: /settings/notifications/webhook:
get: get:
summary: Get webhook notification settings summary: Get webhook notification settings
@ -2903,6 +2958,32 @@ paths:
type: array type: array
items: items:
$ref: '#/components/schemas/User' $ref: '#/components/schemas/User'
/user/registerPushSubscription:
post:
summary: Register a web push /user/registerPushSubscription
description: Registers a web push subscription for the logged-in user
tags:
- users
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
endpoint:
type: string
auth:
type: string
p256dh:
type: string
required:
- endpoint
- auth
- p256dh
responses:
'204':
description: Successfully registered push subscription
/user/{userId}: /user/{userId}:
get: get:
summary: Get user by ID summary: Get user by ID

@ -69,6 +69,7 @@
"swr": "^0.5.5", "swr": "^0.5.5",
"typeorm": "^0.2.32", "typeorm": "^0.2.32",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"web-push": "^3.4.4",
"winston": "^3.3.3", "winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.2", "winston-daily-rotate-file": "^4.5.2",
"xml2js": "^0.4.23", "xml2js": "^0.4.23",
@ -107,6 +108,7 @@
"@types/secure-random-password": "^0.2.0", "@types/secure-random-password": "^0.2.0",
"@types/swagger-ui-express": "^4.1.2", "@types/swagger-ui-express": "^4.1.2",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@types/web-push": "^3.3.0",
"@types/xml2js": "^0.4.8", "@types/xml2js": "^0.4.8",
"@types/yamljs": "^0.2.31", "@types/yamljs": "^0.2.31",
"@types/yup": "^0.29.11", "@types/yup": "^0.29.11",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>You are offline</title>
<!-- Inline the page's stylesheet. -->
<style>
body {
font-family: helvetica, arial, sans-serif;
margin: 2em;
background-color: #111827;
}
h1 {
color: #6366F1;
}
p {
margin-block: 1rem;
}
button {
display: block;
}
</style>
</head>
<body>
<h1>You are offline</h1>
<button type="button">⤾ Reload</button>
<!-- Inline the page's JavaScript file. -->
<script>
// Manual reload feature.
document.querySelector("button").addEventListener("click", () => {
window.location.reload();
});
// Listen to changes in the network state, reload when online.
// This handles the case when the device is completely offline.
window.addEventListener('online', () => {
window.location.reload();
});
// Check if the server is responding and reload the page if it is.
// This handles the case when the device is online, but the server
// is offline or misbehaving.
async function checkNetworkAndReload() {
try {
const response = await fetch('.');
// Verify we get a valid response from the server
if (response.status >= 200 && response.status < 500) {
window.location.reload();
return;
}
} catch {
// Unable to connect to the server, ignore.
}
window.setTimeout(checkNetworkAndReload, 2500);
}
checkNetworkAndReload();
</script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 678 KiB

After

Width:  |  Height:  |  Size: 455 KiB

@ -7,16 +7,28 @@
"src": "/android-chrome-192x192.png", "src": "/android-chrome-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any"
},
{
"src": "/android-chrome-192x192_maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "/android-chrome-512x512.png", "src": "/android-chrome-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any"
},
{
"src": "/android-chrome-512x512_maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable" "purpose": "maskable"
} }
], ],
"theme_color": "#2d3748", "theme_color": "#1f2937",
"background_color": "#2d3748", "background_color": "#1f2937",
"display": "standalone" "display": "standalone"
} }

@ -0,0 +1,136 @@
// Incrementing OFFLINE_VERSION will kick off the install event and force
// previously cached resources to be updated from the network.
// This variable is intentionally declared and unused.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const OFFLINE_VERSION = 3;
const CACHE_NAME = "offline";
// Customize this with a different URL if needed.
const OFFLINE_URL = "/offline.html";
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
// Setting {cache: 'reload'} in the new request will ensure that the
// response isn't fulfilled from the HTTP cache; i.e., it will be from
// the network.
await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
})()
);
// Force the waiting service worker to become the active service worker.
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Enable navigation preload if it's supported.
// See https://developers.google.com/web/updates/2017/02/navigation-preload
if ("navigationPreload" in self.registration) {
await self.registration.navigationPreload.enable();
}
})()
);
// Tell the active service worker to take control of the page immediately.
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
// We only want to call event.respondWith() if this is a navigation request
// for an HTML page.
if (event.request.mode === "navigate") {
event.respondWith(
(async () => {
try {
// First, try to use the navigation preload response if it's supported.
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
return preloadResponse;
}
// Always try the network first.
const networkResponse = await fetch(event.request);
return networkResponse;
} catch (error) {
// catch is only triggered if an exception is thrown, which is likely
// due to a network error.
// If fetch() returns a valid HTTP response with a response code in
// the 4xx or 5xx range, the catch() will NOT be called.
console.log("Fetch failed; returning offline page instead.", error);
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(OFFLINE_URL);
return cachedResponse;
}
})()
);
}
});
self.addEventListener('push', (event) => {
const payload = event.data ? event.data.json() : {};
const options = {
body: payload.message,
icon: payload.image ? payload.image : 'android-chrome-192x192.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: '2',
actionUrl: payload.actionUrl,
requestId: payload.requestId,
},
actions: [],
}
if (payload.actionUrl){
options.actions.push(
{
action: 'viewmedia',
title: 'View Media',
}
);
}
if (payload.notificationType === 'MEDIA_PENDING') {
options.actions.push(
{
action: 'approve',
title: 'Approve',
},
{
action: 'decline',
title: 'Decline',
}
);
}
event.waitUntil(
self.registration.showNotification(payload.subject, options)
);
})
self.addEventListener('notificationclick', (event) => {
const notificationData = event.notification.data;
event.notification.close();
if (event.action === 'viewmedia') {
self.clients.openWindow(notificationData.actionUrl);
} else if (event.action === 'approve') {
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
method: 'POST',
});
self.clients.openWindow(notificationData.actionUrl);
} else if (event.action === 'decline') {
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
method: 'POST',
});
self.clients.openWindow(notificationData.actionUrl);
} else if (notificationData.actionUrl) {
self.clients.openWindow(notificationData.actionUrl);
}
}, false);

@ -29,6 +29,7 @@ import { getSettings } from '../lib/settings';
import logger from '../logger'; import logger from '../logger';
import { MediaRequest } from './MediaRequest'; import { MediaRequest } from './MediaRequest';
import SeasonRequest from './SeasonRequest'; import SeasonRequest from './SeasonRequest';
import { UserPushSubscription } from './UserPushSubscription';
import { UserSettings } from './UserSettings'; import { UserSettings } from './UserSettings';
@Entity() @Entity()
@ -105,6 +106,9 @@ export class User {
}) })
public settings?: UserSettings; public settings?: UserSettings;
@OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user)
public pushSubscriptions: UserPushSubscription[];
@CreateDateColumn() @CreateDateColumn()
public createdAt: Date; public createdAt: Date;

@ -0,0 +1,27 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { User } from './User';
@Entity()
export class UserPushSubscription {
@PrimaryGeneratedColumn()
public id: number;
@ManyToOne(() => User, (user) => user.pushSubscriptions, {
eager: true,
onDelete: 'CASCADE',
})
public user: User;
@Column()
public endpoint: string;
@Column()
public p256dh: string;
@Column({ unique: true })
public auth: string;
constructor(init?: Partial<UserPushSubscription>) {
Object.assign(this, init);
}
}

@ -5,12 +5,15 @@ import {
OneToOne, OneToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
} from 'typeorm'; } from 'typeorm';
import { import { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces';
hasNotificationAgentEnabled, import { hasNotificationType, Notification } from '../lib/notifications';
NotificationAgentType, import { NotificationAgentKey } from '../lib/settings';
} from '../lib/notifications/agenttypes';
import { User } from './User'; import { User } from './User';
export const ALL_NOTIFICATIONS = Object.values(Notification)
.filter((v) => !isNaN(Number(v)))
.reduce((a, v) => a + Number(v), 0);
@Entity() @Entity()
export class UserSettings { export class UserSettings {
constructor(init?: Partial<UserSettings>) { constructor(init?: Partial<UserSettings>) {
@ -24,15 +27,15 @@ export class UserSettings {
@JoinColumn() @JoinColumn()
public user: User; public user: User;
@Column({ default: 'en' })
public locale?: string;
@Column({ nullable: true }) @Column({ nullable: true })
public region?: string; public region?: string;
@Column({ nullable: true }) @Column({ nullable: true })
public originalLanguage?: string; public originalLanguage?: string;
@Column({ type: 'integer', default: NotificationAgentType.EMAIL })
public notificationAgents = NotificationAgentType.EMAIL;
@Column({ nullable: true }) @Column({ nullable: true })
public pgpKey?: string; public pgpKey?: string;
@ -45,7 +48,63 @@ export class UserSettings {
@Column({ nullable: true }) @Column({ nullable: true })
public telegramSendSilently?: boolean; public telegramSendSilently?: boolean;
public hasNotificationAgentEnabled(agent: NotificationAgentType): boolean { @Column({
return !!hasNotificationAgentEnabled(agent, this.notificationAgents); type: 'text',
nullable: true,
transformer: {
from: (value: string | null): Partial<NotificationAgentTypes> => {
const defaultTypes = {
email: ALL_NOTIFICATIONS,
discord: 0,
pushbullet: 0,
pushover: 0,
slack: 0,
telegram: 0,
webhook: 0,
webpush: ALL_NOTIFICATIONS,
};
if (!value) {
return defaultTypes;
}
const values = JSON.parse(value) as Partial<NotificationAgentTypes>;
// Something with the migration to this field has caused some issue where
// the value pre-populates with just a raw "2"? Here we check if that's the case
// and return the default notification types if so
if (typeof values !== 'object') {
return defaultTypes;
}
if (values.email == null) {
values.email = ALL_NOTIFICATIONS;
}
if (values.webpush == null) {
values.webpush = ALL_NOTIFICATIONS;
}
return values;
},
to: (value: Partial<NotificationAgentTypes>): string => {
const allowedKeys = Object.values(NotificationAgentKey);
// Remove any unknown notification agent keys before saving to db
(Object.keys(value) as (keyof NotificationAgentTypes)[]).forEach(
(key) => {
if (!allowedKeys.includes(key)) {
delete value[key];
}
}
);
return JSON.stringify(value);
},
},
})
public notificationTypes: Partial<NotificationAgentTypes>;
public hasNotificationType(key: NotificationAgentKey, type: Notification) {
return hasNotificationType(type, this.notificationTypes[key] ?? 0);
} }
} }

@ -1,30 +1,31 @@
import express, { Request, Response, NextFunction } from 'express'; import { getClientIp } from '@supercharge/request-ip';
import next from 'next';
import path from 'path';
import { createConnection, getRepository } from 'typeorm';
import routes from './routes';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import { TypeormStore } from 'connect-typeorm/out';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import csurf from 'csurf'; import csurf from 'csurf';
import express, { NextFunction, Request, Response } from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
import session, { Store } from 'express-session'; import session, { Store } from 'express-session';
import { TypeormStore } from 'connect-typeorm/out'; import next from 'next';
import YAML from 'yamljs'; import path from 'path';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import * as OpenApiValidator from 'express-openapi-validator'; import { createConnection, getRepository } from 'typeorm';
import YAML from 'yamljs';
import { Session } from './entity/Session'; import { Session } from './entity/Session';
import { getSettings } from './lib/settings';
import logger from './logger';
import { startJobs } from './job/schedule'; import { startJobs } from './job/schedule';
import notificationManager from './lib/notifications'; import notificationManager from './lib/notifications';
import DiscordAgent from './lib/notifications/agents/discord'; import DiscordAgent from './lib/notifications/agents/discord';
import EmailAgent from './lib/notifications/agents/email'; import EmailAgent from './lib/notifications/agents/email';
import TelegramAgent from './lib/notifications/agents/telegram'; import PushbulletAgent from './lib/notifications/agents/pushbullet';
import { getAppVersion } from './utils/appVersion';
import SlackAgent from './lib/notifications/agents/slack';
import PushoverAgent from './lib/notifications/agents/pushover'; import PushoverAgent from './lib/notifications/agents/pushover';
import SlackAgent from './lib/notifications/agents/slack';
import TelegramAgent from './lib/notifications/agents/telegram';
import WebhookAgent from './lib/notifications/agents/webhook'; import WebhookAgent from './lib/notifications/agents/webhook';
import { getClientIp } from '@supercharge/request-ip'; import WebPushAgent from './lib/notifications/agents/webpush';
import PushbulletAgent from './lib/notifications/agents/pushbullet'; import { getSettings } from './lib/settings';
import logger from './logger';
import routes from './routes';
import { getAppVersion } from './utils/appVersion';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
@ -57,6 +58,7 @@ app
new SlackAgent(), new SlackAgent(),
new TelegramAgent(), new TelegramAgent(),
new WebhookAgent(), new WebhookAgent(),
new WebPushAgent(),
]); ]);
// Start Jobs // Start Jobs

@ -30,6 +30,8 @@ export interface PublicSettingsResponse {
originalLanguage: string; originalLanguage: string;
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
cacheImages: boolean; cacheImages: boolean;
vapidPublic: string;
enablePushRegistration: boolean;
} }
export interface CacheItem { export interface CacheItem {

@ -1,5 +1,8 @@
import { NotificationAgentKey } from '../../lib/settings';
export interface UserSettingsGeneralResponse { export interface UserSettingsGeneralResponse {
username?: string; username?: string;
locale?: string;
region?: string; region?: string;
originalLanguage?: string; originalLanguage?: string;
movieQuotaLimit?: number; movieQuotaLimit?: number;
@ -12,8 +15,8 @@ export interface UserSettingsGeneralResponse {
globalTvQuotaDays?: number; globalTvQuotaDays?: number;
} }
export type NotificationAgentTypes = Record<NotificationAgentKey, number>;
export interface UserSettingsNotificationsResponse { export interface UserSettingsNotificationsResponse {
notificationAgents: number;
emailEnabled?: boolean; emailEnabled?: boolean;
pgpKey?: string; pgpKey?: string;
discordEnabled?: boolean; discordEnabled?: boolean;
@ -22,4 +25,6 @@ export interface UserSettingsNotificationsResponse {
telegramBotUsername?: string; telegramBotUsername?: string;
telegramChatId?: string; telegramChatId?: string;
telegramSendSilently?: boolean; telegramSendSilently?: boolean;
webPushEnabled?: boolean;
notificationTypes: Partial<NotificationAgentTypes>;
} }

@ -4,8 +4,11 @@ import { hasNotificationType, Notification } from '..';
import { User } from '../../../entity/User'; import { User } from '../../../entity/User';
import logger from '../../../logger'; import logger from '../../../logger';
import { Permission } from '../../permissions'; import { Permission } from '../../permissions';
import { getSettings, NotificationAgentDiscord } from '../../settings'; import {
import { NotificationAgentType } from '../agenttypes'; getSettings,
NotificationAgentDiscord,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
enum EmbedColors { enum EmbedColors {
@ -227,8 +230,9 @@ class DiscordAgent
if (payload.notifyUser) { if (payload.notifyUser) {
// Mention user who submitted the request // Mention user who submitted the request
if ( if (
payload.notifyUser.settings?.hasNotificationAgentEnabled( payload.notifyUser.settings?.hasNotificationType(
NotificationAgentType.DISCORD NotificationAgentKey.DISCORD,
type
) && ) &&
payload.notifyUser.settings?.discordId payload.notifyUser.settings?.discordId
) { ) {
@ -243,8 +247,9 @@ class DiscordAgent
.filter( .filter(
(user) => (user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) && user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationAgentEnabled( user.settings?.hasNotificationType(
NotificationAgentType.DISCORD NotificationAgentKey.DISCORD,
type
) && ) &&
user.settings?.discordId user.settings?.discordId
) )

@ -7,8 +7,11 @@ import { User } from '../../../entity/User';
import logger from '../../../logger'; import logger from '../../../logger';
import PreparedEmail from '../../email'; import PreparedEmail from '../../email';
import { Permission } from '../../permissions'; import { Permission } from '../../permissions';
import { getSettings, NotificationAgentEmail } from '../../settings'; import {
import { NotificationAgentType } from '../agenttypes'; getSettings,
NotificationAgentEmail,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class EmailAgent class EmailAgent
@ -152,9 +155,13 @@ class EmailAgent
// Send notification to the user who submitted the request // Send notification to the user who submitted the request
if ( if (
!payload.notifyUser.settings || !payload.notifyUser.settings ||
payload.notifyUser.settings.hasNotificationAgentEnabled( // Check if user has email notifications enabled and fallback to true if undefined
NotificationAgentType.EMAIL // since email should default to true
) (payload.notifyUser.settings.hasNotificationType(
NotificationAgentKey.EMAIL,
type
) ??
true)
) { ) {
logger.debug('Sending email notification', { logger.debug('Sending email notification', {
label: 'Notifications', label: 'Notifications',
@ -194,9 +201,13 @@ class EmailAgent
(user) => (user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) && user.hasPermission(Permission.MANAGE_REQUESTS) &&
(!user.settings || (!user.settings ||
user.settings.hasNotificationAgentEnabled( // Check if user has email notifications enabled and fallback to true if undefined
NotificationAgentType.EMAIL // since email should default to true
)) (user.settings.hasNotificationType(
NotificationAgentKey.EMAIL,
type
) ??
true))
) )
.map(async (user) => { .map(async (user) => {
logger.debug('Sending email notification', { logger.debug('Sending email notification', {

@ -2,8 +2,11 @@ import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media'; import { MediaType } from '../../../constants/media';
import logger from '../../../logger'; import logger from '../../../logger';
import { getSettings, NotificationAgentTelegram } from '../../settings'; import {
import { NotificationAgentType } from '../agenttypes'; getSettings,
NotificationAgentKey,
NotificationAgentTelegram,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface TelegramMessagePayload { interface TelegramMessagePayload {
@ -198,8 +201,9 @@ class TelegramAgent
if ( if (
payload.notifyUser && payload.notifyUser &&
payload.notifyUser.settings?.hasNotificationAgentEnabled( payload.notifyUser.settings?.hasNotificationType(
NotificationAgentType.TELEGRAM NotificationAgentKey.TELEGRAM,
type
) && ) &&
payload.notifyUser.settings?.telegramChatId && payload.notifyUser.settings?.telegramChatId &&
payload.notifyUser.settings?.telegramChatId !== payload.notifyUser.settings?.telegramChatId !==

@ -0,0 +1,234 @@
import { getRepository } from 'typeorm';
import webpush from 'web-push';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
import logger from '../../../logger';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentConfig,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface PushNotificationPayload {
notificationType: string;
mediaType?: 'movie' | 'tv';
tmdbId?: number;
subject: string;
message?: string;
image?: string;
actionUrl?: string;
requestId?: number;
}
class WebPushAgent
extends BaseAgent<NotificationAgentConfig>
implements NotificationAgent {
protected getSettings(): NotificationAgentConfig {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.webpush;
}
private getNotificationPayload(
type: Notification,
payload: NotificationPayload
): PushNotificationPayload {
switch (type) {
case Notification.TEST_NOTIFICATION:
return {
notificationType: Notification[type],
subject: payload.subject,
message: payload.message,
};
case Notification.MEDIA_APPROVED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request has been approved.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_AUTO_APPROVED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Automatically approved a new ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request from ${payload.request?.requestedBy.displayName}.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_AVAILABLE:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request is now available!`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_DECLINED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request was declined.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_FAILED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Failed to process ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_PENDING:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Approval required for new ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request from ${payload.request?.requestedBy.displayName}.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
}
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
hasNotificationType(type, this.getSettings().types)
) {
return true;
}
return false;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending web push notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
const userRepository = getRepository(User);
const userPushSubRepository = getRepository(UserPushSubscription);
const settings = getSettings();
let pushSubs: UserPushSubscription[] = [];
const mainUser = await userRepository.findOne({ where: { id: 1 } });
if (
payload.notifyUser &&
// Check if user has webpush notifications enabled and fallback to true if undefined
// since web push should default to true
(payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.WEBPUSH,
type
) ??
true)
) {
const notifySubs = await userPushSubRepository.find({
where: { user: payload.notifyUser.id },
});
pushSubs = notifySubs;
} else if (!payload.notifyUser) {
const users = await userRepository.find();
const manageUsers = users.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
// Check if user has webpush notifications enabled and fallback to true if undefined
// since web push should default to true
(user.settings?.hasNotificationType(
NotificationAgentKey.WEBPUSH,
type
) ??
true)
);
const allSubs = await userPushSubRepository
.createQueryBuilder('pushSub')
.where('pushSub.userId IN (:users)', {
users: manageUsers.map((user) => user.id),
})
.getMany();
pushSubs = allSubs;
}
if (mainUser && pushSubs.length > 0) {
webpush.setVapidDetails(
`mailto:${mainUser.email}`,
settings.vapidPublic,
settings.vapidPrivate
);
Promise.all(
pushSubs.map(async (sub) => {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
auth: sub.auth,
p256dh: sub.p256dh,
},
},
Buffer.from(
JSON.stringify(this.getNotificationPayload(type, payload)),
'utf-8'
)
);
} catch (e) {
// Failed to send notification so we need to remove the subscription
userPushSubRepository.remove(sub);
}
})
);
}
return true;
}
}
export default WebPushAgent;

@ -1,16 +0,0 @@
export enum NotificationAgentType {
NONE = 0,
EMAIL = 2,
DISCORD = 4,
TELEGRAM = 8,
PUSHOVER = 16,
PUSHBULLET = 32,
SLACK = 64,
}
export const hasNotificationAgentEnabled = (
agent: NotificationAgentType,
value: number
): boolean => {
return !!(value & agent);
};

@ -2,6 +2,7 @@ import fs from 'fs';
import { merge } from 'lodash'; import { merge } from 'lodash';
import path from 'path'; import path from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import webpush from 'web-push';
import { Permission } from './permissions'; import { Permission } from './permissions';
export interface Library { export interface Library {
@ -101,6 +102,8 @@ interface FullPublicSettings extends PublicSettings {
originalLanguage: string; originalLanguage: string;
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
cacheImages: boolean; cacheImages: boolean;
vapidPublic: string;
enablePushRegistration: boolean;
} }
export interface NotificationAgentConfig { export interface NotificationAgentConfig {
@ -168,6 +171,17 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
}; };
} }
export enum NotificationAgentKey {
DISCORD = 'discord',
EMAIL = 'email',
PUSHBULLET = 'pushbullet',
PUSHOVER = 'pushover',
SLACK = 'slack',
TELEGRAM = 'telegram',
WEBHOOK = 'webhook',
WEBPUSH = 'webpush',
}
interface NotificationAgents { interface NotificationAgents {
discord: NotificationAgentDiscord; discord: NotificationAgentDiscord;
email: NotificationAgentEmail; email: NotificationAgentEmail;
@ -176,6 +190,7 @@ interface NotificationAgents {
slack: NotificationAgentSlack; slack: NotificationAgentSlack;
telegram: NotificationAgentTelegram; telegram: NotificationAgentTelegram;
webhook: NotificationAgentWebhook; webhook: NotificationAgentWebhook;
webpush: NotificationAgentConfig;
} }
interface NotificationSettings { interface NotificationSettings {
@ -184,6 +199,8 @@ interface NotificationSettings {
interface AllSettings { interface AllSettings {
clientId: string; clientId: string;
vapidPublic: string;
vapidPrivate: string;
main: MainSettings; main: MainSettings;
plex: PlexSettings; plex: PlexSettings;
radarr: RadarrSettings[]; radarr: RadarrSettings[];
@ -202,6 +219,8 @@ class Settings {
constructor(initialSettings?: AllSettings) { constructor(initialSettings?: AllSettings) {
this.data = { this.data = {
clientId: uuidv4(), clientId: uuidv4(),
vapidPrivate: '',
vapidPublic: '',
main: { main: {
apiKey: '', apiKey: '',
applicationTitle: 'Overseerr', applicationTitle: 'Overseerr',
@ -298,6 +317,11 @@ class Settings {
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i', 'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i',
}, },
}, },
webpush: {
enabled: false,
types: 0,
options: {},
},
}, },
}, },
}; };
@ -366,6 +390,8 @@ class Settings {
originalLanguage: this.data.main.originalLanguage, originalLanguage: this.data.main.originalLanguage,
partialRequestsEnabled: this.data.main.partialRequestsEnabled, partialRequestsEnabled: this.data.main.partialRequestsEnabled,
cacheImages: this.data.main.cacheImages, cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic,
enablePushRegistration: this.data.notifications.agents.webpush.enabled,
}; };
} }
@ -386,6 +412,18 @@ class Settings {
return this.data.clientId; return this.data.clientId;
} }
get vapidPublic(): string {
this.generateVapidKeys();
return this.data.vapidPublic;
}
get vapidPrivate(): string {
this.generateVapidKeys();
return this.data.vapidPrivate;
}
public regenerateApiKey(): MainSettings { public regenerateApiKey(): MainSettings {
this.main.apiKey = this.generateApiKey(); this.main.apiKey = this.generateApiKey();
this.save(); this.save();
@ -396,6 +434,15 @@ class Settings {
return Buffer.from(`${Date.now()}${uuidv4()})`).toString('base64'); return Buffer.from(`${Date.now()}${uuidv4()})`).toString('base64');
} }
private generateVapidKeys(force = false): void {
if (!this.data.vapidPublic || !this.data.vapidPrivate || force) {
const vapidKeys = webpush.generateVAPIDKeys();
this.data.vapidPrivate = vapidKeys.privateKey;
this.data.vapidPublic = vapidKeys.publicKey;
this.save();
}
}
/** /**
* Settings Load * Settings Load
* *

@ -28,6 +28,7 @@ export const checkUser: Middleware = async (req, _res, next) => {
if (user) { if (user) {
req.user = user; req.user = user;
req.locale = user.settings?.locale;
} }
} }
next(); next();

@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUserPushSubscriptions1618912653565
implements MigrationInterface {
name = 'CreateUserPushSubscriptions1618912653565';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"))`
);
await queryRunner.query(
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "user_push_subscription"`
);
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
);
await queryRunner.query(
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"))`
);
await queryRunner.query(
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "temporary_user_push_subscription"`
);
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
}
}

@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsLocale1619239659754 implements MigrationInterface {
name = 'AddUserSettingsLocale1619239659754';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT ('en'), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "user_settings"`
);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
);
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "temporary_user_settings"`
);
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
}
}

@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsNotificationTypes1619339817343
implements MigrationInterface {
name = 'AddUserSettingsNotificationTypes1619339817343';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT ('en'), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"`
);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
);
await queryRunner.query(
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT ('en'), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"`
);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
);
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT ('en'), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"`
);
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
await queryRunner.query(
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
);
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT ('en'), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"`
);
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
}
}

@ -11,7 +11,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
try { try {
const collection = await tmdb.getCollection({ const collection = await tmdb.getCollection({
collectionId: Number(req.params.id), collectionId: Number(req.params.id),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(

@ -1,16 +1,16 @@
import { Router } from 'express'; import { Router } from 'express';
import { sortBy } from 'lodash';
import TheMovieDb from '../api/themoviedb'; import TheMovieDb from '../api/themoviedb';
import { mapMovieResult, mapTvResult, mapPersonResult } from '../models/Search';
import Media from '../entity/Media';
import { isMovie, isPerson } from '../utils/typeHelpers';
import { MediaType } from '../constants/media'; import { MediaType } from '../constants/media';
import { getSettings } from '../lib/settings'; import Media from '../entity/Media';
import { User } from '../entity/User'; import { User } from '../entity/User';
import { GenreSliderItem } from '../interfaces/api/discoverInterfaces';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { mapProductionCompany } from '../models/Movie'; import { mapProductionCompany } from '../models/Movie';
import { mapMovieResult, mapPersonResult, mapTvResult } from '../models/Search';
import { mapNetwork } from '../models/Tv'; import { mapNetwork } from '../models/Tv';
import logger from '../logger'; import { isMovie, isPerson } from '../utils/typeHelpers';
import { sortBy } from 'lodash';
import { GenreSliderItem } from '../interfaces/api/discoverInterfaces';
const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => { const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => {
const settings = getSettings(); const settings = getSettings();
@ -42,7 +42,7 @@ discoverRoutes.get('/movies', async (req, res) => {
const data = await tmdb.getDiscoverMovies({ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
genre: req.query.genre ? Number(req.query.genre) : undefined, genre: req.query.genre ? Number(req.query.genre) : undefined,
studio: req.query.studio ? Number(req.query.studio) : undefined, studio: req.query.studio ? Number(req.query.studio) : undefined,
}); });
@ -83,7 +83,7 @@ discoverRoutes.get<{ language: string }>(
const data = await tmdb.getDiscoverMovies({ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
originalLanguage: req.params.language, originalLanguage: req.params.language,
}); });
@ -115,7 +115,7 @@ discoverRoutes.get<{ genreId: string }>(
const tmdb = createTmdbWithRegionLanaguage(req.user); const tmdb = createTmdbWithRegionLanaguage(req.user);
const genres = await tmdb.getMovieGenres({ const genres = await tmdb.getMovieGenres({
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const genre = genres.find( const genre = genres.find(
@ -128,7 +128,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverMovies({ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
genre: Number(req.params.genreId), genre: Number(req.params.genreId),
}); });
@ -164,7 +164,7 @@ discoverRoutes.get<{ studioId: string }>(
const data = await tmdb.getDiscoverMovies({ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
studio: Number(req.params.studioId), studio: Number(req.params.studioId),
}); });
@ -204,7 +204,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res) => {
const data = await tmdb.getDiscoverMovies({ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
primaryReleaseDateGte: date, primaryReleaseDateGte: date,
}); });
@ -232,7 +232,7 @@ discoverRoutes.get('/tv', async (req, res) => {
const data = await tmdb.getDiscoverTv({ const data = await tmdb.getDiscoverTv({
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
genre: req.query.genre ? Number(req.query.genre) : undefined, genre: req.query.genre ? Number(req.query.genre) : undefined,
network: req.query.network ? Number(req.query.network) : undefined, network: req.query.network ? Number(req.query.network) : undefined,
}); });
@ -273,7 +273,7 @@ discoverRoutes.get<{ language: string }>(
const data = await tmdb.getDiscoverTv({ const data = await tmdb.getDiscoverTv({
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
originalLanguage: req.params.language, originalLanguage: req.params.language,
}); });
@ -304,7 +304,7 @@ discoverRoutes.get<{ genreId: string }>(
const tmdb = createTmdbWithRegionLanaguage(req.user); const tmdb = createTmdbWithRegionLanaguage(req.user);
const genres = await tmdb.getTvGenres({ const genres = await tmdb.getTvGenres({
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const genre = genres.find( const genre = genres.find(
@ -317,7 +317,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverTv({ const data = await tmdb.getDiscoverTv({
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
genre: Number(req.params.genreId), genre: Number(req.params.genreId),
}); });
@ -352,7 +352,7 @@ discoverRoutes.get<{ networkId: string }>(
const data = await tmdb.getDiscoverTv({ const data = await tmdb.getDiscoverTv({
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
network: Number(req.params.networkId), network: Number(req.params.networkId),
}); });
@ -392,7 +392,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res) => {
const data = await tmdb.getDiscoverTv({ const data = await tmdb.getDiscoverTv({
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
firstAirDateGte: date, firstAirDateGte: date,
}); });
@ -420,7 +420,7 @@ discoverRoutes.get('/trending', async (req, res) => {
const data = await tmdb.getAllTrending({ const data = await tmdb.getAllTrending({
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(
@ -461,7 +461,7 @@ discoverRoutes.get<{ keywordId: string }>(
const data = await tmdb.getMoviesByKeyword({ const data = await tmdb.getMoviesByKeyword({
keywordId: Number(req.params.keywordId), keywordId: Number(req.params.keywordId),
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(
@ -494,7 +494,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
const mappedGenres: GenreSliderItem[] = []; const mappedGenres: GenreSliderItem[] = [];
const genres = await tmdb.getMovieGenres({ const genres = await tmdb.getMovieGenres({
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
await Promise.all( await Promise.all(
@ -535,7 +535,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
const mappedGenres: GenreSliderItem[] = []; const mappedGenres: GenreSliderItem[] = [];
const genres = await tmdb.getTvGenres({ const genres = await tmdb.getTvGenres({
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
await Promise.all( await Promise.all(

@ -138,7 +138,7 @@ router.get('/genres/movie', isAuthenticated(), async (req, res) => {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const genres = await tmdb.getMovieGenres({ const genres = await tmdb.getMovieGenres({
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
return res.status(200).json(genres); return res.status(200).json(genres);
@ -148,7 +148,7 @@ router.get('/genres/tv', isAuthenticated(), async (req, res) => {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const genres = await tmdb.getTvGenres({ const genres = await tmdb.getTvGenres({
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
return res.status(200).json(genres); return res.status(200).json(genres);

@ -1,11 +1,11 @@
import { Router } from 'express'; import { Router } from 'express';
import RottenTomatoes from '../api/rottentomatoes';
import TheMovieDb from '../api/themoviedb'; import TheMovieDb from '../api/themoviedb';
import { mapMovieDetails } from '../models/Movie'; import { MediaType } from '../constants/media';
import { mapMovieResult } from '../models/Search';
import Media from '../entity/Media'; import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes';
import logger from '../logger'; import logger from '../logger';
import { MediaType } from '../constants/media'; import { mapMovieDetails } from '../models/Movie';
import { mapMovieResult } from '../models/Search';
const movieRoutes = Router(); const movieRoutes = Router();
@ -15,7 +15,7 @@ movieRoutes.get('/:id', async (req, res, next) => {
try { try {
const tmdbMovie = await tmdb.getMovie({ const tmdbMovie = await tmdb.getMovie({
movieId: Number(req.params.id), movieId: Number(req.params.id),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE); const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
@ -36,7 +36,7 @@ movieRoutes.get('/:id/recommendations', async (req, res) => {
const results = await tmdb.getMovieRecommendations({ const results = await tmdb.getMovieRecommendations({
movieId: Number(req.params.id), movieId: Number(req.params.id),
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(
@ -64,7 +64,7 @@ movieRoutes.get('/:id/similar', async (req, res) => {
const results = await tmdb.getMovieSimilar({ const results = await tmdb.getMovieSimilar({
movieId: Number(req.params.id), movieId: Number(req.params.id),
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(

@ -16,7 +16,7 @@ personRoutes.get('/:id', async (req, res, next) => {
try { try {
const person = await tmdb.getPerson({ const person = await tmdb.getPerson({
personId: Number(req.params.id), personId: Number(req.params.id),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
return res.status(200).json(mapPersonDetails(person)); return res.status(200).json(mapPersonDetails(person));
} catch (e) { } catch (e) {
@ -30,7 +30,7 @@ personRoutes.get('/:id/combined_credits', async (req, res) => {
const combinedCredits = await tmdb.getPersonCombinedCredits({ const combinedCredits = await tmdb.getPersonCombinedCredits({
personId: Number(req.params.id), personId: Number(req.params.id),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const castMedia = await Media.getRelatedMedia( const castMedia = await Media.getRelatedMedia(

@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import TheMovieDb from '../api/themoviedb'; import TheMovieDb from '../api/themoviedb';
import { mapSearchResults } from '../models/Search';
import Media from '../entity/Media'; import Media from '../entity/Media';
import { mapSearchResults } from '../models/Search';
const searchRoutes = Router(); const searchRoutes = Router();
@ -11,7 +11,7 @@ searchRoutes.get('/', async (req, res) => {
const results = await tmdb.searchMulti({ const results = await tmdb.searchMulti({
query: req.query.query as string, query: req.query.query as string,
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(

@ -191,7 +191,7 @@ serviceRoutes.get<{ tmdbId: string }>(
try { try {
const tv = await tmdb.getTvShow({ const tv = await tmdb.getTvShow({
tvId: Number(req.params.tmdbId), tvId: Number(req.params.tmdbId),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const response = await sonarr.getSeriesByTitle(tv.name); const response = await sonarr.getSeriesByTitle(tv.name);

@ -7,6 +7,7 @@ import PushoverAgent from '../../lib/notifications/agents/pushover';
import SlackAgent from '../../lib/notifications/agents/slack'; import SlackAgent from '../../lib/notifications/agents/slack';
import TelegramAgent from '../../lib/notifications/agents/telegram'; import TelegramAgent from '../../lib/notifications/agents/telegram';
import WebhookAgent from '../../lib/notifications/agents/webhook'; import WebhookAgent from '../../lib/notifications/agents/webhook';
import WebPushAgent from '../../lib/notifications/agents/webpush';
import { getSettings } from '../../lib/settings'; import { getSettings } from '../../lib/settings';
const notificationRoutes = Router(); const notificationRoutes = Router();
@ -215,6 +216,40 @@ notificationRoutes.post('/email/test', (req, res, next) => {
return res.status(204).send(); return res.status(204).send();
}); });
notificationRoutes.get('/webpush', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.notifications.agents.webpush);
});
notificationRoutes.post('/webpush', (req, res) => {
const settings = getSettings();
settings.notifications.agents.webpush = req.body;
settings.save();
res.status(200).json(settings.notifications.agents.webpush);
});
notificationRoutes.post('/webpush/test', (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information missing from request',
});
}
const webpushAgent = new WebPushAgent(req.body);
webpushAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
});
notificationRoutes.get('/webhook', (_req, res) => { notificationRoutes.get('/webhook', (_req, res) => {
const settings = getSettings(); const settings = getSettings();

@ -1,11 +1,11 @@
import { Router } from 'express'; import { Router } from 'express';
import RottenTomatoes from '../api/rottentomatoes';
import TheMovieDb from '../api/themoviedb'; import TheMovieDb from '../api/themoviedb';
import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv'; import { MediaType } from '../constants/media';
import { mapTvResult } from '../models/Search';
import Media from '../entity/Media'; import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes';
import logger from '../logger'; import logger from '../logger';
import { MediaType } from '../constants/media'; import { mapTvResult } from '../models/Search';
import { mapSeasonWithEpisodes, mapTvDetails } from '../models/Tv';
const tvRoutes = Router(); const tvRoutes = Router();
@ -14,7 +14,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
try { try {
const tv = await tmdb.getTvShow({ const tv = await tmdb.getTvShow({
tvId: Number(req.params.id), tvId: Number(req.params.id),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getMedia(tv.id, MediaType.TV); const media = await Media.getMedia(tv.id, MediaType.TV);
@ -35,7 +35,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res) => {
const season = await tmdb.getTvSeason({ const season = await tmdb.getTvSeason({
tvId: Number(req.params.id), tvId: Number(req.params.id),
seasonNumber: Number(req.params.seasonNumber), seasonNumber: Number(req.params.seasonNumber),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
return res.status(200).json(mapSeasonWithEpisodes(season)); return res.status(200).json(mapSeasonWithEpisodes(season));
@ -47,7 +47,7 @@ tvRoutes.get('/:id/recommendations', async (req, res) => {
const results = await tmdb.getTvRecommendations({ const results = await tmdb.getTvRecommendations({
tvId: Number(req.params.id), tvId: Number(req.params.id),
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(
@ -75,7 +75,7 @@ tvRoutes.get('/:id/similar', async (req, res) => {
const results = await tmdb.getTvSimilar({ const results = await tmdb.getTvSimilar({
tvId: Number(req.params.id), tvId: Number(req.params.id),
page: Number(req.query.page), page: Number(req.query.page),
language: req.query.language as string, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(

@ -5,6 +5,7 @@ import PlexTvAPI from '../../api/plextv';
import { UserType } from '../../constants/user'; import { UserType } from '../../constants/user';
import { MediaRequest } from '../../entity/MediaRequest'; import { MediaRequest } from '../../entity/MediaRequest';
import { User } from '../../entity/User'; import { User } from '../../entity/User';
import { UserPushSubscription } from '../../entity/UserPushSubscription';
import { import {
QuotaResponse, QuotaResponse,
UserRequestsResponse, UserRequestsResponse,
@ -127,6 +128,48 @@ router.post(
} }
); );
router.post<
never,
unknown,
{
endpoint: string;
p256dh: string;
auth: string;
}
>('/registerPushSubscription', async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
const existingSubs = await userPushSubRepository.find({
where: { auth: req.body.auth },
});
if (existingSubs.length > 0) {
logger.debug(
'User push subscription already exists. Skipping registration.',
{ label: 'API' }
);
return res.status(204).send();
}
const userPushSubscription = new UserPushSubscription({
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
user: req.user,
});
userPushSubRepository.save(userPushSubscription);
return res.status(204).send();
} catch (e) {
logger.error('Failed to register user push subscription', {
label: 'API',
});
next({ status: 500, message: 'Failed to register subscription.' });
}
});
router.get<{ id: string }>('/:id', async (req, res, next) => { router.get<{ id: string }>('/:id', async (req, res, next) => {
try { try {
const userRepository = getRepository(User); const userRepository = getRepository(User);

@ -7,7 +7,6 @@ import {
UserSettingsGeneralResponse, UserSettingsGeneralResponse,
UserSettingsNotificationsResponse, UserSettingsNotificationsResponse,
} from '../../interfaces/api/userSettingsInterfaces'; } from '../../interfaces/api/userSettingsInterfaces';
import { NotificationAgentType } from '../../lib/notifications/agenttypes';
import { Permission } from '../../lib/permissions'; import { Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings'; import { getSettings } from '../../lib/settings';
import logger from '../../logger'; import logger from '../../logger';
@ -52,6 +51,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
return res.status(200).json({ return res.status(200).json({
username: user.username, username: user.username,
locale: user.settings?.locale,
region: user.settings?.region, region: user.settings?.region,
originalLanguage: user.settings?.originalLanguage, originalLanguage: user.settings?.originalLanguage,
movieQuotaLimit: user.movieQuotaLimit, movieQuotaLimit: user.movieQuotaLimit,
@ -109,17 +109,24 @@ userSettingsRoutes.post<
if (!user.settings) { if (!user.settings) {
user.settings = new UserSettings({ user.settings = new UserSettings({
user: req.user, user: req.user,
locale: req.body.locale,
region: req.body.region, region: req.body.region,
originalLanguage: req.body.originalLanguage, originalLanguage: req.body.originalLanguage,
}); });
} else { } else {
user.settings.region = req.body.region; (user.settings.locale = req.body.locale),
(user.settings.region = req.body.region);
user.settings.originalLanguage = req.body.originalLanguage; user.settings.originalLanguage = req.body.originalLanguage;
} }
await userRepository.save(user); await userRepository.save(user);
return res.status(200).json({ username: user.username }); return res.status(200).json({
username: user.username,
region: user.settings.region,
locale: user.settings.locale,
originalLanguage: user.settings.originalLanguage,
});
} catch (e) { } catch (e) {
next({ status: 500, message: e.message }); next({ status: 500, message: e.message });
} }
@ -243,8 +250,6 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
} }
return res.status(200).json({ return res.status(200).json({
notificationAgents:
user.settings?.notificationAgents ?? NotificationAgentType.EMAIL,
emailEnabled: settings?.notifications.agents.email.enabled, emailEnabled: settings?.notifications.agents.email.enabled,
pgpKey: user.settings?.pgpKey, pgpKey: user.settings?.pgpKey,
discordEnabled: settings?.notifications.agents.discord.enabled, discordEnabled: settings?.notifications.agents.discord.enabled,
@ -254,6 +259,8 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
settings?.notifications.agents.telegram.options.botUsername, settings?.notifications.agents.telegram.options.botUsername,
telegramChatId: user.settings?.telegramChatId, telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently, telegramSendSilently: user?.settings?.telegramSendSilently,
webPushEnabled: settings?.notifications.agents.webpush.enabled,
notificationTypes: user.settings?.notificationTypes ?? {},
}); });
} catch (e) { } catch (e) {
next({ status: 500, message: e.message }); next({ status: 500, message: e.message });
@ -287,30 +294,32 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
if (!user.settings) { if (!user.settings) {
user.settings = new UserSettings({ user.settings = new UserSettings({
user: req.user, user: req.user,
notificationAgents:
req.body.notificationAgents ?? NotificationAgentType.EMAIL,
pgpKey: req.body.pgpKey, pgpKey: req.body.pgpKey,
discordId: req.body.discordId, discordId: req.body.discordId,
telegramChatId: req.body.telegramChatId, telegramChatId: req.body.telegramChatId,
telegramSendSilently: req.body.telegramSendSilently, telegramSendSilently: req.body.telegramSendSilently,
notificationTypes: req.body.notificationTypes,
}); });
} else { } else {
user.settings.notificationAgents =
req.body.notificationAgents ?? NotificationAgentType.EMAIL;
user.settings.pgpKey = req.body.pgpKey; user.settings.pgpKey = req.body.pgpKey;
user.settings.discordId = req.body.discordId; user.settings.discordId = req.body.discordId;
user.settings.telegramChatId = req.body.telegramChatId; user.settings.telegramChatId = req.body.telegramChatId;
user.settings.telegramSendSilently = req.body.telegramSendSilently; user.settings.telegramSendSilently = req.body.telegramSendSilently;
user.settings.notificationTypes = Object.assign(
{},
user.settings.notificationTypes,
req.body.notificationTypes
);
} }
userRepository.save(user); userRepository.save(user);
return res.status(200).json({ return res.status(200).json({
notificationAgents: user.settings?.notificationAgents,
pgpKey: user.settings?.pgpKey, pgpKey: user.settings?.pgpKey,
discordId: user.settings?.discordId, discordId: user.settings?.discordId,
telegramChatId: user.settings?.telegramChatId, telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently, telegramSendSilently: user?.settings?.telegramSendSilently,
notificationTypes: user.settings.notificationTypes,
}); });
} catch (e) { } catch (e) {
next({ status: 500, message: e.message }); next({ status: 500, message: e.message });

@ -6,6 +6,7 @@ declare global {
namespace Express { namespace Express {
export interface Request { export interface Request {
user?: User; user?: User;
locale?: string;
} }
} }

@ -3,14 +3,13 @@ import axios from 'axios';
import { uniq } from 'lodash'; import { uniq } from 'lodash';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useContext, useState } from 'react'; import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
import { MediaStatus } from '../../../server/constants/media'; import { MediaStatus } from '../../../server/constants/media';
import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { MediaRequest } from '../../../server/entity/MediaRequest';
import type { Collection } from '../../../server/models/Collection'; import type { Collection } from '../../../server/models/Collection';
import { LanguageContext } from '../../context/LanguageContext';
import useSettings from '../../hooks/useSettings'; import useSettings from '../../hooks/useSettings';
import { Permission, useUser } from '../../hooks/useUser'; import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
@ -48,14 +47,13 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
const router = useRouter(); const router = useRouter();
const settings = useSettings(); const settings = useSettings();
const { addToast } = useToasts(); const { addToast } = useToasts();
const { locale } = useContext(LanguageContext);
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const [requestModal, setRequestModal] = useState(false); const [requestModal, setRequestModal] = useState(false);
const [isRequesting, setRequesting] = useState(false); const [isRequesting, setRequesting] = useState(false);
const [is4k, setIs4k] = useState(false); const [is4k, setIs4k] = useState(false);
const { data, error, revalidate } = useSWR<Collection>( const { data, error, revalidate } = useSWR<Collection>(
`/api/v1/collection/${router.query.collectionId}?language=${locale}`, `/api/v1/collection/${router.query.collectionId}`,
{ {
initialData: collection, initialData: collection,
revalidateOnMount: true, revalidateOnMount: true,
@ -63,7 +61,7 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
); );
const { data: genres } = useSWR<{ id: number; name: string }[]>( const { data: genres } = useSWR<{ id: number; name: string }[]>(
`/api/v1/genres/movie?language=${locale}` `/api/v1/genres/movie`
); );
if (!data && !error) { if (!data && !error) {

@ -1,24 +1,22 @@
import React, { useContext } from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import GenreCard from '../../GenreCard';
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
import { LanguageContext } from '../../../context/LanguageContext'; import Error from '../../../pages/_error';
import { genreColorMap } from '../constants';
import PageTitle from '../../Common/PageTitle';
import Header from '../../Common/Header'; import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner'; import LoadingSpinner from '../../Common/LoadingSpinner';
import Error from '../../../pages/_error'; import PageTitle from '../../Common/PageTitle';
import GenreCard from '../../GenreCard';
import { genreColorMap } from '../constants';
const messages = defineMessages({ const messages = defineMessages({
moviegenres: 'Movie Genres', moviegenres: 'Movie Genres',
}); });
const MovieGenreList: React.FC = () => { const MovieGenreList: React.FC = () => {
const { locale } = useContext(LanguageContext);
const intl = useIntl(); const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>( const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/movie?language=${locale}` `/api/v1/discover/genreslider/movie`
); );
if (!data && !error) { if (!data && !error) {

@ -1,10 +1,9 @@
import { ArrowCircleRightIcon } from '@heroicons/react/outline'; import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import Link from 'next/link'; import Link from 'next/link';
import React, { useContext } from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
import { LanguageContext } from '../../../context/LanguageContext';
import GenreCard from '../../GenreCard'; import GenreCard from '../../GenreCard';
import Slider from '../../Slider'; import Slider from '../../Slider';
import { genreColorMap } from '../constants'; import { genreColorMap } from '../constants';
@ -14,10 +13,9 @@ const messages = defineMessages({
}); });
const MovieGenreSlider: React.FC = () => { const MovieGenreSlider: React.FC = () => {
const { locale } = useContext(LanguageContext);
const intl = useIntl(); const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>( const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/movie?language=${locale}`, `/api/v1/discover/genreslider/movie`,
{ {
refreshInterval: 0, refreshInterval: 0,
revalidateOnFocus: false, revalidateOnFocus: false,

@ -1,24 +1,22 @@
import React, { useContext } from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import GenreCard from '../../GenreCard';
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
import { LanguageContext } from '../../../context/LanguageContext'; import Error from '../../../pages/_error';
import { genreColorMap } from '../constants';
import PageTitle from '../../Common/PageTitle';
import Header from '../../Common/Header'; import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner'; import LoadingSpinner from '../../Common/LoadingSpinner';
import Error from '../../../pages/_error'; import PageTitle from '../../Common/PageTitle';
import GenreCard from '../../GenreCard';
import { genreColorMap } from '../constants';
const messages = defineMessages({ const messages = defineMessages({
seriesgenres: 'Series Genres', seriesgenres: 'Series Genres',
}); });
const TvGenreList: React.FC = () => { const TvGenreList: React.FC = () => {
const { locale } = useContext(LanguageContext);
const intl = useIntl(); const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>( const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/tv?language=${locale}` `/api/v1/discover/genreslider/tv`
); );
if (!data && !error) { if (!data && !error) {

@ -1,10 +1,9 @@
import { ArrowCircleRightIcon } from '@heroicons/react/outline'; import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import Link from 'next/link'; import Link from 'next/link';
import React, { useContext } from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
import { LanguageContext } from '../../../context/LanguageContext';
import GenreCard from '../../GenreCard'; import GenreCard from '../../GenreCard';
import Slider from '../../Slider'; import Slider from '../../Slider';
import { genreColorMap } from '../constants'; import { genreColorMap } from '../constants';
@ -14,10 +13,9 @@ const messages = defineMessages({
}); });
const TvGenreSlider: React.FC = () => { const TvGenreSlider: React.FC = () => {
const { locale } = useContext(LanguageContext);
const intl = useIntl(); const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>( const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/tv?language=${locale}`, `/api/v1/discover/genreslider/tv`,
{ {
refreshInterval: 0, refreshInterval: 0,
revalidateOnFocus: false, revalidateOnFocus: false,

@ -1,11 +1,11 @@
import React, { useContext } from 'react'; import React from 'react';
import { MediaType } from '../../../server/constants/media'; import { MediaType } from '../../../server/constants/media';
import ImdbLogo from '../../assets/services/imdb.svg'; import ImdbLogo from '../../assets/services/imdb.svg';
import PlexLogo from '../../assets/services/plex.svg'; import PlexLogo from '../../assets/services/plex.svg';
import RTLogo from '../../assets/services/rt.svg'; import RTLogo from '../../assets/services/rt.svg';
import TmdbLogo from '../../assets/services/tmdb.svg'; import TmdbLogo from '../../assets/services/tmdb.svg';
import TvdbLogo from '../../assets/services/tvdb.svg'; import TvdbLogo from '../../assets/services/tvdb.svg';
import { LanguageContext } from '../../context/LanguageContext'; import useLocale from '../../hooks/useLocale';
interface ExternalLinkBlockProps { interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv';
@ -24,7 +24,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
rtUrl, rtUrl,
plexUrl, plexUrl,
}) => { }) => {
const { locale } = useContext(LanguageContext); const { locale } = useLocale();
return ( return (
<div className="flex items-center justify-center w-full space-x-5"> <div className="flex items-center justify-center w-full space-x-5">

@ -1,93 +1,22 @@
import { TranslateIcon } from '@heroicons/react/solid'; import { TranslateIcon } from '@heroicons/react/solid';
import React, { useContext, useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { import {
availableLanguages,
AvailableLocales, AvailableLocales,
LanguageContext,
} from '../../../context/LanguageContext'; } from '../../../context/LanguageContext';
import useClickOutside from '../../../hooks/useClickOutside'; import useClickOutside from '../../../hooks/useClickOutside';
import useLocale from '../../../hooks/useLocale';
import Transition from '../../Transition'; import Transition from '../../Transition';
const messages = defineMessages({ const messages = defineMessages({
changelanguage: 'Change Language', changelanguage: 'Change Language',
}); });
type AvailableLanguageObject = Record<
string,
{ code: AvailableLocales; display: string }
>;
const availableLanguages: AvailableLanguageObject = {
ca: {
code: 'ca',
display: 'Català',
},
de: {
code: 'de',
display: 'Deutsch',
},
en: {
code: 'en',
display: 'English',
},
es: {
code: 'es',
display: 'Español',
},
fr: {
code: 'fr',
display: 'Français',
},
it: {
code: 'it',
display: 'Italiano',
},
hu: {
code: 'hu',
display: 'Magyar',
},
nl: {
code: 'nl',
display: 'Nederlands',
},
'nb-NO': {
code: 'nb-NO',
display: 'Norsk Bokmål',
},
'pt-BR': {
code: 'pt-BR',
display: 'Português (Brasil)',
},
'pt-PT': {
code: 'pt-PT',
display: 'Português (Portugal)',
},
sv: {
code: 'sv',
display: 'Svenska',
},
ru: {
code: 'ru',
display: 'pусский',
},
sr: {
code: 'sr',
display: 'српски језик‬',
},
ja: {
code: 'ja',
display: '日本語',
},
'zh-TW': {
code: 'zh-TW',
display: '中文(臺灣)',
},
};
const LanguagePicker: React.FC = () => { const LanguagePicker: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const { locale, setLocale } = useContext(LanguageContext); const { locale, setLocale } = useLocale();
const [isDropdownOpen, setDropdownOpen] = useState(false); const [isDropdownOpen, setDropdownOpen] = useState(false);
useClickOutside(dropdownRef, () => setDropdownOpen(false)); useClickOutside(dropdownRef, () => setDropdownOpen(false));

@ -1,10 +1,9 @@
import { MenuAlt2Icon } from '@heroicons/react/outline'; import { MenuAlt2Icon } from '@heroicons/react/outline';
import { InformationCircleIcon } from '@heroicons/react/solid'; import { ArrowLeftIcon, InformationCircleIcon } from '@heroicons/react/solid';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { Permission, useUser } from '../../hooks/useUser'; import { Permission, useUser } from '../../hooks/useUser';
import LanguagePicker from './LanguagePicker';
import SearchInput from './SearchInput'; import SearchInput from './SearchInput';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import UserDropdown from './UserDropdown'; import UserDropdown from './UserDropdown';
@ -23,7 +22,7 @@ const Layout: React.FC = ({ children }) => {
useEffect(() => { useEffect(() => {
const updateScrolled = () => { const updateScrolled = () => {
if (window.pageYOffset > 60) { if (window.pageYOffset > 20) {
setIsScrolled(true); setIsScrolled(true);
} else { } else {
setIsScrolled(false); setIsScrolled(false);
@ -55,16 +54,25 @@ const Layout: React.FC = ({ children }) => {
}} }}
> >
<button <button
className="px-4 text-gray-200 focus:outline-none focus:bg-gray-300 focus:text-gray-600 md:hidden" className={`px-4 ${
isScrolled ? 'text-gray-200' : 'text-gray-400'
} focus:outline-none md:hidden transition duration-300`}
aria-label="Open sidebar" aria-label="Open sidebar"
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
> >
<MenuAlt2Icon className="w-6 h-6" /> <MenuAlt2Icon className="w-6 h-6" />
</button> </button>
<div className="flex justify-between flex-1 pr-4 md:pr-4 md:pl-4"> <div className="flex items-center justify-between flex-1 pr-4 md:pr-4 md:pl-4">
<button
className={`mr-2 ${
isScrolled ? 'text-gray-200' : 'text-gray-400'
} transition duration-300 hover:text-white pwa-only focus:outline-none focus:text-white`}
onClick={() => router.back()}
>
<ArrowLeftIcon className="w-7" />
</button>
<SearchInput /> <SearchInput />
<div className="flex items-center ml-2"> <div className="flex items-center ml-2">
<LanguagePicker />
<UserDropdown /> <UserDropdown />
</div> </div>
</div> </div>

@ -1,6 +1,6 @@
import { ArrowCircleRightIcon } from '@heroicons/react/outline'; import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import Link from 'next/link'; import Link from 'next/link';
import React, { useContext, useEffect } from 'react'; import React, { useEffect } from 'react';
import { useSWRInfinite } from 'swr'; import { useSWRInfinite } from 'swr';
import { MediaStatus } from '../../../server/constants/media'; import { MediaStatus } from '../../../server/constants/media';
import type { import type {
@ -8,7 +8,6 @@ import type {
PersonResult, PersonResult,
TvResult, TvResult,
} from '../../../server/models/Search'; } from '../../../server/models/Search';
import { LanguageContext } from '../../context/LanguageContext';
import useSettings from '../../hooks/useSettings'; import useSettings from '../../hooks/useSettings';
import PersonCard from '../PersonCard'; import PersonCard from '../PersonCard';
import Slider from '../Slider'; import Slider from '../Slider';
@ -38,14 +37,13 @@ const MediaSlider: React.FC<MediaSliderProps> = ({
hideWhenEmpty = false, hideWhenEmpty = false,
}) => { }) => {
const settings = useSettings(); const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, setSize, size } = useSWRInfinite<MixedResult>( const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
(pageIndex: number, previousPageData: MixedResult | null) => { (pageIndex: number, previousPageData: MixedResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null; return null;
} }
return `${url}?page=${pageIndex + 1}&language=${locale}`; return `${url}?page=${pageIndex + 1}`;
}, },
{ {
initialSize: 2, initialSize: 2,

@ -1,15 +1,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useContext } from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import { MovieDetails } from '../../../../server/models/Movie'; import { MovieDetails } from '../../../../server/models/Movie';
import { LanguageContext } from '../../../context/LanguageContext';
import Error from '../../../pages/_error'; import Error from '../../../pages/_error';
import Header from '../../Common/Header'; import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner'; import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard';
import PageTitle from '../../Common/PageTitle'; import PageTitle from '../../Common/PageTitle';
import PersonCard from '../../PersonCard';
const messages = defineMessages({ const messages = defineMessages({
fullcast: 'Full Cast', fullcast: 'Full Cast',
@ -18,9 +17,8 @@ const messages = defineMessages({
const MovieCast: React.FC = () => { const MovieCast: React.FC = () => {
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const { locale } = useContext(LanguageContext);
const { data, error } = useSWR<MovieDetails>( const { data, error } = useSWR<MovieDetails>(
`/api/v1/movie/${router.query.movieId}?language=${locale}` `/api/v1/movie/${router.query.movieId}`
); );
if (!data && !error) { if (!data && !error) {

@ -1,15 +1,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useContext } from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import { MovieDetails } from '../../../../server/models/Movie'; import { MovieDetails } from '../../../../server/models/Movie';
import { LanguageContext } from '../../../context/LanguageContext';
import Error from '../../../pages/_error'; import Error from '../../../pages/_error';
import Header from '../../Common/Header'; import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner'; import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard';
import PageTitle from '../../Common/PageTitle'; import PageTitle from '../../Common/PageTitle';
import PersonCard from '../../PersonCard';
const messages = defineMessages({ const messages = defineMessages({
fullcrew: 'Full Crew', fullcrew: 'Full Crew',
@ -18,9 +17,8 @@ const messages = defineMessages({
const MovieCrew: React.FC = () => { const MovieCrew: React.FC = () => {
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const { locale } = useContext(LanguageContext);
const { data, error } = useSWR<MovieDetails>( const { data, error } = useSWR<MovieDetails>(
`/api/v1/movie/${router.query.movieId}?language=${locale}` `/api/v1/movie/${router.query.movieId}`
); );
if (!data && !error) { if (!data && !error) {

@ -1,16 +1,15 @@
import React, { useContext } from 'react'; import Link from 'next/link';
import useSWR from 'swr';
import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Header from '../Common/Header'; import React from 'react';
import type { MovieDetails } from '../../../server/models/Movie';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import PageTitle from '../Common/PageTitle'; import useSWR from 'swr';
import type { MovieDetails } from '../../../server/models/Movie';
import type { MovieResult } from '../../../server/models/Search';
import useDiscover from '../../hooks/useDiscover'; import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error'; import Error from '../../pages/_error';
import Link from 'next/link'; import Header from '../Common/Header';
import ListView from '../Common/ListView';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({ const messages = defineMessages({
recommendations: 'Recommendations', recommendations: 'Recommendations',
@ -19,9 +18,8 @@ const messages = defineMessages({
const MovieRecommendations: React.FC = () => { const MovieRecommendations: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const router = useRouter(); const router = useRouter();
const { locale } = useContext(LanguageContext);
const { data: movieData } = useSWR<MovieDetails>( const { data: movieData } = useSWR<MovieDetails>(
`/api/v1/movie/${router.query.movieId}?language=${locale}` `/api/v1/movie/${router.query.movieId}`
); );
const { const {
isLoadingInitialData, isLoadingInitialData,

@ -1,16 +1,15 @@
import React, { useContext } from 'react'; import Link from 'next/link';
import useSWR from 'swr';
import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Header from '../Common/Header'; import React from 'react';
import { LanguageContext } from '../../context/LanguageContext';
import type { MovieDetails } from '../../../server/models/Movie';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import PageTitle from '../Common/PageTitle'; import useSWR from 'swr';
import type { MovieDetails } from '../../../server/models/Movie';
import type { MovieResult } from '../../../server/models/Search';
import useDiscover from '../../hooks/useDiscover'; import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error'; import Error from '../../pages/_error';
import Link from 'next/link'; import Header from '../Common/Header';
import ListView from '../Common/ListView';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({ const messages = defineMessages({
similar: 'Similar Titles', similar: 'Similar Titles',
@ -19,9 +18,8 @@ const messages = defineMessages({
const MovieSimilar: React.FC = () => { const MovieSimilar: React.FC = () => {
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const { locale } = useContext(LanguageContext);
const { data: movieData } = useSWR<MovieDetails>( const { data: movieData } = useSWR<MovieDetails>(
`/api/v1/movie/${router.query.movieId}?language=${locale}` `/api/v1/movie/${router.query.movieId}`
); );
const { const {
isLoadingInitialData, isLoadingInitialData,

@ -12,7 +12,7 @@ import {
import axios from 'axios'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useContext, useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import type { RTRating } from '../../../server/api/rottentomatoes'; import type { RTRating } from '../../../server/api/rottentomatoes';
@ -23,7 +23,7 @@ import RTAudRotten from '../../assets/rt_aud_rotten.svg';
import RTFresh from '../../assets/rt_fresh.svg'; import RTFresh from '../../assets/rt_fresh.svg';
import RTRotten from '../../assets/rt_rotten.svg'; import RTRotten from '../../assets/rt_rotten.svg';
import TmdbLogo from '../../assets/tmdb_logo.svg'; import TmdbLogo from '../../assets/tmdb_logo.svg';
import { LanguageContext } from '../../context/LanguageContext'; import useLocale from '../../hooks/useLocale';
import useSettings from '../../hooks/useSettings'; import useSettings from '../../hooks/useSettings';
import { Permission, useUser } from '../../hooks/useUser'; import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
@ -84,11 +84,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const { user, hasPermission } = useUser(); const { user, hasPermission } = useUser();
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const { locale } = useContext(LanguageContext); const { locale } = useLocale();
const [showManager, setShowManager] = useState(false); const [showManager, setShowManager] = useState(false);
const { data, error, revalidate } = useSWR<MovieDetailsType>( const { data, error, revalidate } = useSWR<MovieDetailsType>(
`/api/v1/movie/${router.query.movieId}?language=${locale}`, `/api/v1/movie/${router.query.movieId}`,
{ {
initialData: movie, initialData: movie,
} }

@ -53,6 +53,10 @@ export enum Notification {
MEDIA_AUTO_APPROVED = 128, MEDIA_AUTO_APPROVED = 128,
} }
export const ALL_NOTIFICATIONS = Object.values(Notification)
.filter((v) => !isNaN(Number(v)))
.reduce((a, v) => a + Number(v), 0);
export interface NotificationItem { export interface NotificationItem {
id: string; id: string;
name: string; name: string;

@ -0,0 +1,183 @@
import React from 'react';
interface PWAHeaderProps {
applicationTitle?: string;
}
const PWAHeader: React.FC<PWAHeaderProps> = ({ applicationTitle }) => {
return (
<>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-16x16.png"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2048-2732.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2732-2048.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1668-2388.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2388-1668.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1536-2048.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2048-1536.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1668-2224.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2224-1668.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1620-2160.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2160-1620.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1284-2778.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2778-1284.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1170-2532.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2532-1170.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1125-2436.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2436-1125.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1242-2688.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2688-1242.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-828-1792.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1792-828.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1242-2208.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2208-1242.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-750-1334.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1334-750.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-640-1136.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1136-640.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<link
rel="manifest"
href="/site.webmanifest"
crossOrigin="use-credentials"
/>
<meta name="application-name" content={applicationTitle ?? 'Overseerr'} />
<meta
name="apple-mobile-web-app-title"
content={applicationTitle ?? 'Overseerr'}
/>
<meta
name="description"
content="Request and Media Discovery Application"
/>
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#1f2937" />
</>
);
};
export default PWAHeader;

@ -1,13 +1,12 @@
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useContext, useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import TruncateMarkup from 'react-truncate-markup'; import TruncateMarkup from 'react-truncate-markup';
import useSWR from 'swr'; import useSWR from 'swr';
import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces'; import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces';
import type { PersonDetail } from '../../../server/models/Person'; import type { PersonDetail } from '../../../server/models/Person';
import Ellipsis from '../../assets/ellipsis.svg'; import Ellipsis from '../../assets/ellipsis.svg';
import { LanguageContext } from '../../context/LanguageContext';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
import Error from '../../pages/_error'; import Error from '../../pages/_error';
import CachedImage from '../Common/CachedImage'; import CachedImage from '../Common/CachedImage';
@ -27,10 +26,9 @@ const messages = defineMessages({
const PersonDetails: React.FC = () => { const PersonDetails: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const { locale } = useContext(LanguageContext);
const router = useRouter(); const router = useRouter();
const { data, error } = useSWR<PersonDetail>( const { data, error } = useSWR<PersonDetail>(
`/api/v1/person/${router.query.personId}?language=${locale}` `/api/v1/person/${router.query.personId}`
); );
const [showBio, setShowBio] = useState(false); const [showBio, setShowBio] = useState(false);
@ -38,7 +36,7 @@ const PersonDetails: React.FC = () => {
data: combinedCredits, data: combinedCredits,
error: errorCombinedCredits, error: errorCombinedCredits,
} = useSWR<PersonCombinedCreditsResponse>( } = useSWR<PersonCombinedCreditsResponse>(
`/api/v1/person/${router.query.personId}/combined_credits?language=${locale}` `/api/v1/person/${router.query.personId}/combined_credits`
); );
const sortedCast = useMemo(() => { const sortedCast = useMemo(() => {

@ -1,7 +1,7 @@
import { CheckIcon, TrashIcon, XIcon } from '@heroicons/react/solid'; import { CheckIcon, TrashIcon, XIcon } from '@heroicons/react/solid';
import axios from 'axios'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
import React, { useContext, useEffect } from 'react'; import React, { useEffect } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR, { mutate } from 'swr'; import useSWR, { mutate } from 'swr';
@ -12,7 +12,6 @@ import {
import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { MediaRequest } from '../../../server/entity/MediaRequest';
import type { MovieDetails } from '../../../server/models/Movie'; import type { MovieDetails } from '../../../server/models/Movie';
import type { TvDetails } from '../../../server/models/Tv'; import type { TvDetails } from '../../../server/models/Tv';
import { LanguageContext } from '../../context/LanguageContext';
import { Permission, useUser } from '../../hooks/useUser'; import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
import { withProperties } from '../../utils/typeHelpers'; import { withProperties } from '../../utils/typeHelpers';
@ -92,13 +91,12 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
}); });
const intl = useIntl(); const intl = useIntl();
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const { locale } = useContext(LanguageContext);
const url = const url =
request.type === 'movie' request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}` ? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`; : `/api/v1/tv/${request.media.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>( const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}?language=${locale}` : null inView ? `${url}` : null
); );
const { const {
data: requestData, data: requestData,

@ -7,7 +7,7 @@ import {
} from '@heroicons/react/solid'; } from '@heroicons/react/solid';
import axios from 'axios'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
import React, { useContext, useState } from 'react'; import React, { useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@ -19,7 +19,6 @@ import {
import type { MediaRequest } from '../../../../server/entity/MediaRequest'; import type { MediaRequest } from '../../../../server/entity/MediaRequest';
import type { MovieDetails } from '../../../../server/models/Movie'; import type { MovieDetails } from '../../../../server/models/Movie';
import type { TvDetails } from '../../../../server/models/Tv'; import type { TvDetails } from '../../../../server/models/Tv';
import { LanguageContext } from '../../../context/LanguageContext';
import { Permission, useUser } from '../../../hooks/useUser'; import { Permission, useUser } from '../../../hooks/useUser';
import globalMessages from '../../../i18n/globalMessages'; import globalMessages from '../../../i18n/globalMessages';
import Badge from '../../Common/Badge'; import Badge from '../../Common/Badge';
@ -99,13 +98,12 @@ const RequestItem: React.FC<RequestItemProps> = ({
const intl = useIntl(); const intl = useIntl();
const { user, hasPermission } = useUser(); const { user, hasPermission } = useUser();
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const { locale } = useContext(LanguageContext);
const url = const url =
request.type === 'movie' request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}` ? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`; : `/api/v1/tv/${request.media.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>( const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}?language=${locale}` : null inView ? `${url}` : null
); );
const { data: requestData, revalidate, mutate } = useSWR<MediaRequest>( const { data: requestData, revalidate, mutate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`, `/api/v1/request/${request.id}`,

@ -0,0 +1,49 @@
/* eslint-disable no-console */
import axios from 'axios';
import React, { useEffect } from 'react';
import useSettings from '../../hooks/useSettings';
import { useUser } from '../../hooks/useUser';
const ServiceWorkerSetup: React.FC = () => {
const { currentSettings } = useSettings();
const { user } = useUser();
useEffect(() => {
if ('serviceWorker' in navigator && user?.id) {
navigator.serviceWorker
.register('/sw.js')
.then(async (registration) => {
console.log(
'[SW] Registration successful, scope is:',
registration.scope
);
if (currentSettings.enablePushRegistration) {
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: currentSettings.vapidPublic,
});
const parsedSub = JSON.parse(JSON.stringify(sub));
if (parsedSub.keys.p256dh && parsedSub.keys.auth) {
await axios.post('/api/v1/user/registerPushSubscription', {
endpoint: parsedSub.endpoint,
p256dh: parsedSub.keys.p256dh,
auth: parsedSub.keys.auth,
});
}
}
})
.catch(function (error) {
console.log('[SW] Service worker registration failed, error:', error);
});
}
}, [
user,
currentSettings.vapidPublic,
currentSettings.enablePushRegistration,
]);
return null;
};
export default ServiceWorkerSetup;

@ -0,0 +1,122 @@
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import globalMessages from '../../../../i18n/globalMessages';
import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner';
import NotificationTypeSelector from '../../../NotificationTypeSelector';
const messages = defineMessages({
agentenabled: 'Enable Agent',
webpushsettingssaved: 'Web push notification settings saved successfully!',
webpushsettingsfailed: 'Web push notification settings failed to save.',
testsent: 'Web push test notification sent!',
});
const NotificationsWebPush: React.FC = () => {
const intl = useIntl();
const { addToast } = useToasts();
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/webpush'
);
if (!data && !error) {
return <LoadingSpinner />;
}
return (
<>
<Formik
initialValues={{
enabled: data.enabled,
types: data.types,
}}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/webpush', {
enabled: values.enabled,
types: values.types,
options: {},
});
addToast(intl.formatMessage(messages.webpushsettingssaved), {
appearance: 'success',
autoDismiss: true,
});
} catch (e) {
addToast(intl.formatMessage(messages.webpushsettingsfailed), {
appearance: 'error',
autoDismiss: true,
});
} finally {
revalidate();
}
}}
>
{({ isSubmitting, values, isValid, setFieldValue }) => {
const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/webpush/test', {
enabled: true,
types: values.types,
options: {},
});
addToast(intl.formatMessage(messages.testsent), {
appearance: 'info',
autoDismiss: true,
});
};
return (
<Form className="section">
<div className="form-row">
<label htmlFor="enabled" className="checkbox-label">
{intl.formatMessage(messages.agentenabled)}
</label>
<div className="form-input">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
/>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid}
onClick={(e) => {
e.preventDefault();
testSettings();
}}
>
{intl.formatMessage(globalMessages.test)}
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</Button>
</span>
</div>
</div>
</Form>
);
}}
</Formik>
</>
);
};
export default NotificationsWebPush;

@ -1,5 +1,5 @@
import { AtSymbolIcon } from '@heroicons/react/outline'; import { AtSymbolIcon } from '@heroicons/react/outline';
import { LightningBoltIcon } from '@heroicons/react/solid'; import { CloudIcon, LightningBoltIcon } from '@heroicons/react/solid';
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import DiscordLogo from '../../assets/extlogos/discord.svg'; import DiscordLogo from '../../assets/extlogos/discord.svg';
@ -18,6 +18,7 @@ const messages = defineMessages({
'Configure and enable notification agents.', 'Configure and enable notification agents.',
email: 'Email', email: 'Email',
webhook: 'Webhook', webhook: 'Webhook',
webpush: 'Web Push',
}); });
const SettingsNotifications: React.FC = ({ children }) => { const SettingsNotifications: React.FC = ({ children }) => {
@ -90,6 +91,17 @@ const SettingsNotifications: React.FC = ({ children }) => {
route: '/settings/notifications/telegram', route: '/settings/notifications/telegram',
regex: /^\/settings\/notifications\/telegram/, regex: /^\/settings\/notifications\/telegram/,
}, },
{
text: intl.formatMessage(messages.webpush),
content: (
<span className="flex items-center">
<CloudIcon className="h-4 mr-2" />
{intl.formatMessage(messages.webpush)}
</span>
),
route: '/settings/notifications/webpush',
regex: /^\/settings\/notifications\/webpush/,
},
{ {
text: intl.formatMessage(messages.webhook), text: intl.formatMessage(messages.webhook),
content: ( content: (

@ -1,10 +1,9 @@
import React, { useContext } from 'react'; import React from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import useSWR from 'swr'; import useSWR from 'swr';
import TitleCard from '.';
import type { MovieDetails } from '../../../server/models/Movie'; import type { MovieDetails } from '../../../server/models/Movie';
import type { TvDetails } from '../../../server/models/Tv'; import type { TvDetails } from '../../../server/models/Tv';
import TitleCard from '.';
import { LanguageContext } from '../../context/LanguageContext';
interface TmdbTitleCardProps { interface TmdbTitleCardProps {
tmdbId: number; tmdbId: number;
@ -19,11 +18,10 @@ const TmdbTitleCard: React.FC<TmdbTitleCardProps> = ({ tmdbId, type }) => {
const { ref, inView } = useInView({ const { ref, inView } = useInView({
triggerOnce: true, triggerOnce: true,
}); });
const { locale } = useContext(LanguageContext);
const url = const url =
type === 'movie' ? `/api/v1/movie/${tmdbId}` : `/api/v1/tv/${tmdbId}`; type === 'movie' ? `/api/v1/movie/${tmdbId}` : `/api/v1/tv/${tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>( const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}?language=${locale}` : null inView ? `${url}` : null
); );
if (!title && !error) { if (!title && !error) {

@ -7,41 +7,57 @@ import {
import { XIcon } from '@heroicons/react/solid'; import { XIcon } from '@heroicons/react/solid';
import React from 'react'; import React from 'react';
import type { ToastProps } from 'react-toast-notifications'; import type { ToastProps } from 'react-toast-notifications';
import Transition from '../Transition';
const Toast: React.FC<ToastProps> = ({ appearance, children, onDismiss }) => { const Toast: React.FC<ToastProps> = ({
appearance,
children,
onDismiss,
transitionState,
}) => {
return ( return (
<div className="flex items-end justify-center px-2 py-2 pointer-events-none toast sm:items-start sm:justify-end"> <div className="flex items-end justify-center px-2 py-2 pointer-events-none toast sm:items-start sm:justify-end">
<div className="w-full max-w-sm bg-gray-700 rounded-lg shadow-lg pointer-events-auto"> <Transition
<div className="overflow-hidden rounded-lg ring-1 ring-black ring-opacity-5"> show={transitionState === 'entered'}
<div className="p-4"> enter="transition duration-300 transform-gpu"
<div className="flex items-start"> enterFrom="opacity-0 scale-95"
<div className="flex-shrink-0"> enterTo="opacity-100 scale-100"
{appearance === 'success' && ( leave="transition duration-150 transform-gpu"
<CheckCircleIcon className="w-6 h-6 text-green-400" /> leaveFrom="opacity-100 scale-100"
)} leaveTo="opacity-0 scale-90"
{appearance === 'error' && ( >
<ExclamationCircleIcon className="w-6 h-6 text-red-500" /> <div className="w-full max-w-sm bg-gray-800 rounded-lg shadow-lg pointer-events-auto ring-1 ring-gray-500">
)} <div className="overflow-hidden rounded-lg ring-1 ring-black ring-opacity-5">
{appearance === 'info' && ( <div className="p-4">
<InformationCircleIcon className="w-6 h-6 text-indigo-500" /> <div className="flex items-start">
)} <div className="flex-shrink-0">
{appearance === 'warning' && ( {appearance === 'success' && (
<ExclamationIcon className="w-6 h-6 text-orange-400" /> <CheckCircleIcon className="w-6 h-6 text-green-400" />
)} )}
</div> {appearance === 'error' && (
<div className="flex-1 w-0 ml-3 text-white">{children}</div> <ExclamationCircleIcon className="w-6 h-6 text-red-500" />
<div className="flex flex-shrink-0 ml-4"> )}
<button {appearance === 'info' && (
onClick={() => onDismiss()} <InformationCircleIcon className="w-6 h-6 text-indigo-500" />
className="inline-flex text-gray-400 transition duration-150 ease-in-out focus:outline-none focus:text-gray-500" )}
> {appearance === 'warning' && (
<XIcon className="w-5 h-5" /> <ExclamationIcon className="w-6 h-6 text-orange-400" />
</button> )}
</div>
<div className="flex-1 w-0 ml-3 text-white">{children}</div>
<div className="flex flex-shrink-0 ml-4">
<button
onClick={() => onDismiss()}
className="inline-flex text-gray-400 transition duration-150 ease-in-out focus:outline-none focus:text-gray-500"
>
<XIcon className="w-5 h-5" />
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </Transition>
</div> </div>
); );
}; };

@ -0,0 +1,22 @@
import React from 'react';
import { ToastContainerProps } from 'react-toast-notifications';
const ToastContainer: React.FC<ToastContainerProps> = ({
hasToasts,
...props
}) => {
return (
<div
id="toast-container"
className="fixed max-w-full max-h-full overflow-hidden top-4 right-4"
style={{
pointerEvents: hasToasts ? 'all' : 'none',
zIndex: 10000,
paddingTop: 'env(safe-area-inset-top)',
}}
{...props}
/>
);
};
export default ToastContainer;

@ -1,15 +1,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useContext } from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import type { TvDetails } from '../../../../server/models/Tv'; import type { TvDetails } from '../../../../server/models/Tv';
import { LanguageContext } from '../../../context/LanguageContext';
import Error from '../../../pages/_error'; import Error from '../../../pages/_error';
import Header from '../../Common/Header'; import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner'; import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard';
import PageTitle from '../../Common/PageTitle'; import PageTitle from '../../Common/PageTitle';
import PersonCard from '../../PersonCard';
const messages = defineMessages({ const messages = defineMessages({
fullseriescast: 'Full Series Cast', fullseriescast: 'Full Series Cast',
@ -18,10 +17,7 @@ const messages = defineMessages({
const TvCast: React.FC = () => { const TvCast: React.FC = () => {
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const { locale } = useContext(LanguageContext); const { data, error } = useSWR<TvDetails>(`/api/v1/tv/${router.query.tvId}`);
const { data, error } = useSWR<TvDetails>(
`/api/v1/tv/${router.query.tvId}?language=${locale}`
);
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;

@ -1,15 +1,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useContext } from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import type { TvDetails } from '../../../../server/models/Tv'; import type { TvDetails } from '../../../../server/models/Tv';
import { LanguageContext } from '../../../context/LanguageContext';
import Error from '../../../pages/_error'; import Error from '../../../pages/_error';
import Header from '../../Common/Header'; import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner'; import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard';
import PageTitle from '../../Common/PageTitle'; import PageTitle from '../../Common/PageTitle';
import PersonCard from '../../PersonCard';
const messages = defineMessages({ const messages = defineMessages({
fullseriescrew: 'Full Series Crew', fullseriescrew: 'Full Series Crew',
@ -18,10 +17,7 @@ const messages = defineMessages({
const TvCrew: React.FC = () => { const TvCrew: React.FC = () => {
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const { locale } = useContext(LanguageContext); const { data, error } = useSWR<TvDetails>(`/api/v1/tv/${router.query.tvId}`);
const { data, error } = useSWR<TvDetails>(
`/api/v1/tv/${router.query.tvId}?language=${locale}`
);
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;

@ -1,16 +1,15 @@
import React, { useContext } from 'react'; import Link from 'next/link';
import useSWR from 'swr';
import type { TvResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { LanguageContext } from '../../context/LanguageContext'; import React from 'react';
import Header from '../Common/Header';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { TvResult } from '../../../server/models/Search';
import { TvDetails } from '../../../server/models/Tv'; import { TvDetails } from '../../../server/models/Tv';
import PageTitle from '../Common/PageTitle';
import Error from '../../pages/_error';
import useDiscover from '../../hooks/useDiscover'; import useDiscover from '../../hooks/useDiscover';
import Link from 'next/link'; import Error from '../../pages/_error';
import Header from '../Common/Header';
import ListView from '../Common/ListView';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({ const messages = defineMessages({
recommendations: 'Recommendations', recommendations: 'Recommendations',
@ -19,10 +18,7 @@ const messages = defineMessages({
const TvRecommendations: React.FC = () => { const TvRecommendations: React.FC = () => {
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const { locale } = useContext(LanguageContext); const { data: tvData } = useSWR<TvDetails>(`/api/v1/tv/${router.query.tvId}`);
const { data: tvData } = useSWR<TvDetails>(
`/api/v1/tv/${router.query.tvId}?language=${locale}`
);
const { const {
isLoadingInitialData, isLoadingInitialData,
isEmpty, isEmpty,

@ -1,16 +1,15 @@
import React, { useContext } from 'react'; import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import type { TvResult } from '../../../server/models/Search'; import type { TvResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { useRouter } from 'next/router';
import { LanguageContext } from '../../context/LanguageContext';
import { useIntl, defineMessages } from 'react-intl';
import type { TvDetails } from '../../../server/models/Tv'; import type { TvDetails } from '../../../server/models/Tv';
import Header from '../Common/Header';
import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover'; import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error'; import Error from '../../pages/_error';
import Link from 'next/link'; import Header from '../Common/Header';
import ListView from '../Common/ListView';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({ const messages = defineMessages({
similar: 'Similar Series', similar: 'Similar Series',
@ -19,10 +18,7 @@ const messages = defineMessages({
const TvSimilar: React.FC = () => { const TvSimilar: React.FC = () => {
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const { locale } = useContext(LanguageContext); const { data: tvData } = useSWR<TvDetails>(`/api/v1/tv/${router.query.tvId}`);
const { data: tvData } = useSWR<TvDetails>(
`/api/v1/tv/${router.query.tvId}?language=${locale}`
);
const { const {
isLoadingInitialData, isLoadingInitialData,
isEmpty, isEmpty,

@ -12,7 +12,7 @@ import {
import axios from 'axios'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useContext, useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import type { RTRating } from '../../../server/api/rottentomatoes'; import type { RTRating } from '../../../server/api/rottentomatoes';
@ -25,7 +25,7 @@ import RTAudRotten from '../../assets/rt_aud_rotten.svg';
import RTFresh from '../../assets/rt_fresh.svg'; import RTFresh from '../../assets/rt_fresh.svg';
import RTRotten from '../../assets/rt_rotten.svg'; import RTRotten from '../../assets/rt_rotten.svg';
import TmdbLogo from '../../assets/tmdb_logo.svg'; import TmdbLogo from '../../assets/tmdb_logo.svg';
import { LanguageContext } from '../../context/LanguageContext'; import useLocale from '../../hooks/useLocale';
import useSettings from '../../hooks/useSettings'; import useSettings from '../../hooks/useSettings';
import { Permission, useUser } from '../../hooks/useUser'; import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
@ -91,12 +91,12 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
const { user, hasPermission } = useUser(); const { user, hasPermission } = useUser();
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const { locale } = useContext(LanguageContext); const { locale } = useLocale();
const [showRequestModal, setShowRequestModal] = useState(false); const [showRequestModal, setShowRequestModal] = useState(false);
const [showManager, setShowManager] = useState(false); const [showManager, setShowManager] = useState(false);
const { data, error, revalidate } = useSWR<TvDetailsType>( const { data, error, revalidate } = useSWR<TvDetailsType>(
`/api/v1/tv/${router.query.tvId}?language=${locale}`, `/api/v1/tv/${router.query.tvId}`,
{ {
initialData: tv, initialData: tv,
} }

@ -7,6 +7,8 @@ import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
import { UserSettingsGeneralResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces'; import { UserSettingsGeneralResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
import { Language } from '../../../../../server/lib/settings'; import { Language } from '../../../../../server/lib/settings';
import { availableLanguages } from '../../../../context/LanguageContext';
import useLocale from '../../../../hooks/useLocale';
import useSettings from '../../../../hooks/useSettings'; import useSettings from '../../../../hooks/useSettings';
import { Permission, UserType, useUser } from '../../../../hooks/useUser'; import { Permission, UserType, useUser } from '../../../../hooks/useUser';
import globalMessages from '../../../../i18n/globalMessages'; import globalMessages from '../../../../i18n/globalMessages';
@ -39,11 +41,13 @@ const messages = defineMessages({
movierequestlimit: 'Movie Request Limit', movierequestlimit: 'Movie Request Limit',
seriesrequestlimit: 'Series Request Limit', seriesrequestlimit: 'Series Request Limit',
enableOverride: 'Enable Override', enableOverride: 'Enable Override',
applanguage: 'Display Language',
}); });
const UserGeneralSettings: React.FC = () => { const UserGeneralSettings: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const { addToast } = useToasts(); const { addToast } = useToasts();
const { locale, setLocale } = useLocale();
const [movieQuotaEnabled, setMovieQuotaEnabled] = useState(false); const [movieQuotaEnabled, setMovieQuotaEnabled] = useState(false);
const [tvQuotaEnabled, setTvQuotaEnabled] = useState(false); const [tvQuotaEnabled, setTvQuotaEnabled] = useState(false);
const router = useRouter(); const router = useRouter();
@ -115,6 +119,7 @@ const UserGeneralSettings: React.FC = () => {
</div> </div>
<Formik <Formik
initialValues={{ initialValues={{
locale,
displayName: data?.username, displayName: data?.username,
region: data?.region, region: data?.region,
originalLanguage: data?.originalLanguage, originalLanguage: data?.originalLanguage,
@ -136,8 +141,13 @@ const UserGeneralSettings: React.FC = () => {
movieQuotaDays: movieQuotaEnabled ? values.movieQuotaDays : null, movieQuotaDays: movieQuotaEnabled ? values.movieQuotaDays : null,
tvQuotaLimit: tvQuotaEnabled ? values.tvQuotaLimit : null, tvQuotaLimit: tvQuotaEnabled ? values.tvQuotaLimit : null,
tvQuotaDays: tvQuotaEnabled ? values.tvQuotaDays : null, tvQuotaDays: tvQuotaEnabled ? values.tvQuotaDays : null,
locale: values.locale,
}); });
if (setLocale) {
setLocale(values.locale);
}
addToast(intl.formatMessage(messages.toastSettingsSuccess), { addToast(intl.formatMessage(messages.toastSettingsSuccess), {
autoDismiss: true, autoDismiss: true,
appearance: 'success', appearance: 'success',
@ -206,6 +216,24 @@ const UserGeneralSettings: React.FC = () => {
)} )}
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="locale" className="text-label">
{intl.formatMessage(messages.applanguage)}
</label>
<div className="form-input">
<div className="form-input-field">
<Field as="select" id="locale" name="locale">
{(Object.keys(
availableLanguages
) as (keyof typeof availableLanguages)[]).map((key) => (
<option key={key} value={availableLanguages[key].code}>
{availableLanguages[key].display}
</option>
))}
</Field>
</div>
</div>
</div>
<div className="form-row"> <div className="form-row">
<label htmlFor="displayName" className="text-label"> <label htmlFor="displayName" className="text-label">
<span>{intl.formatMessage(messages.region)}</span> <span>{intl.formatMessage(messages.region)}</span>

@ -1,20 +1,17 @@
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces'; import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
import {
hasNotificationAgentEnabled,
NotificationAgentType,
} from '../../../../../server/lib/notifications/agenttypes';
import { useUser } from '../../../../hooks/useUser'; import { useUser } from '../../../../hooks/useUser';
import globalMessages from '../../../../i18n/globalMessages'; import globalMessages from '../../../../i18n/globalMessages';
import Button from '../../../Common/Button'; import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner'; import LoadingSpinner from '../../../Common/LoadingSpinner';
import { ALL_NOTIFICATIONS } from '../../../NotificationTypeSelector';
const messages = defineMessages({ const messages = defineMessages({
discordsettingssaved: 'Discord notification settings saved successfully!', discordsettingssaved: 'Discord notification settings saved successfully!',
@ -30,18 +27,11 @@ const UserNotificationsDiscord: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const { addToast } = useToasts(); const { addToast } = useToasts();
const router = useRouter(); const router = useRouter();
const [notificationAgents, setNotificationAgents] = useState(0);
const { user } = useUser({ id: Number(router.query.userId) }); const { user } = useUser({ id: Number(router.query.userId) });
const { data, error, revalidate } = useSWR<UserSettingsNotificationsResponse>( const { data, error, revalidate } = useSWR<UserSettingsNotificationsResponse>(
user ? `/api/v1/user/${user?.id}/settings/notifications` : null user ? `/api/v1/user/${user?.id}/settings/notifications` : null
); );
useEffect(() => {
setNotificationAgents(
data?.notificationAgents ?? NotificationAgentType.EMAIL
);
}, [data]);
const UserNotificationsDiscordSchema = Yup.object().shape({ const UserNotificationsDiscordSchema = Yup.object().shape({
discordId: Yup.string() discordId: Yup.string()
.when('enableDiscord', { .when('enableDiscord', {
@ -61,10 +51,7 @@ const UserNotificationsDiscord: React.FC = () => {
return ( return (
<Formik <Formik
initialValues={{ initialValues={{
enableDiscord: hasNotificationAgentEnabled( enableDiscord: !!data?.notificationTypes.discord,
NotificationAgentType.DISCORD,
data?.notificationAgents ?? NotificationAgentType.EMAIL
),
discordId: data?.discordId, discordId: data?.discordId,
}} }}
validationSchema={UserNotificationsDiscordSchema} validationSchema={UserNotificationsDiscordSchema}
@ -72,11 +59,13 @@ const UserNotificationsDiscord: React.FC = () => {
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, {
notificationAgents,
pgpKey: data?.pgpKey, pgpKey: data?.pgpKey,
discordId: values.discordId, discordId: values.discordId,
telegramChatId: data?.telegramChatId, telegramChatId: data?.telegramChatId,
telegramSendSilently: data?.telegramSendSilently, telegramSendSilently: data?.telegramSendSilently,
notificationTypes: {
discord: values.enableDiscord ? ALL_NOTIFICATIONS : 0,
},
}); });
addToast(intl.formatMessage(messages.discordsettingssaved), { addToast(intl.formatMessage(messages.discordsettingssaved), {
appearance: 'success', appearance: 'success',
@ -92,7 +81,7 @@ const UserNotificationsDiscord: React.FC = () => {
} }
}} }}
> >
{({ errors, touched, isSubmitting, isValid, values, setFieldValue }) => { {({ errors, touched, isSubmitting, isValid }) => {
return ( return (
<Form className="section"> <Form className="section">
{data?.discordEnabled && ( {data?.discordEnabled && (
@ -105,21 +94,6 @@ const UserNotificationsDiscord: React.FC = () => {
type="checkbox" type="checkbox"
id="enableDiscord" id="enableDiscord"
name="enableDiscord" name="enableDiscord"
checked={hasNotificationAgentEnabled(
NotificationAgentType.DISCORD,
notificationAgents
)}
onChange={() => {
setNotificationAgents(
hasNotificationAgentEnabled(
NotificationAgentType.DISCORD,
notificationAgents
)
? notificationAgents - NotificationAgentType.DISCORD
: notificationAgents + NotificationAgentType.DISCORD
);
setFieldValue('enableDiscord', !values.enableDiscord);
}}
/> />
</div> </div>
</div> </div>

@ -1,21 +1,18 @@
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces'; import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
import {
hasNotificationAgentEnabled,
NotificationAgentType,
} from '../../../../../server/lib/notifications/agenttypes';
import { useUser } from '../../../../hooks/useUser'; import { useUser } from '../../../../hooks/useUser';
import globalMessages from '../../../../i18n/globalMessages'; import globalMessages from '../../../../i18n/globalMessages';
import Badge from '../../../Common/Badge'; import Badge from '../../../Common/Badge';
import Button from '../../../Common/Button'; import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner'; import LoadingSpinner from '../../../Common/LoadingSpinner';
import { ALL_NOTIFICATIONS } from '../../../NotificationTypeSelector';
import { OpenPgpLink } from '../../../Settings/Notifications/NotificationsEmail'; import { OpenPgpLink } from '../../../Settings/Notifications/NotificationsEmail';
const messages = defineMessages({ const messages = defineMessages({
@ -32,18 +29,11 @@ const UserEmailSettings: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const { addToast } = useToasts(); const { addToast } = useToasts();
const router = useRouter(); const router = useRouter();
const [notificationAgents, setNotificationAgents] = useState(0);
const { user } = useUser({ id: Number(router.query.userId) }); const { user } = useUser({ id: Number(router.query.userId) });
const { data, error, revalidate } = useSWR<UserSettingsNotificationsResponse>( const { data, error, revalidate } = useSWR<UserSettingsNotificationsResponse>(
user ? `/api/v1/user/${user?.id}/settings/notifications` : null user ? `/api/v1/user/${user?.id}/settings/notifications` : null
); );
useEffect(() => {
setNotificationAgents(
data?.notificationAgents ?? NotificationAgentType.EMAIL
);
}, [data]);
const UserNotificationsEmailSchema = Yup.object().shape({ const UserNotificationsEmailSchema = Yup.object().shape({
pgpKey: Yup.string() pgpKey: Yup.string()
.nullable() .nullable()
@ -60,10 +50,7 @@ const UserEmailSettings: React.FC = () => {
return ( return (
<Formik <Formik
initialValues={{ initialValues={{
enableEmail: hasNotificationAgentEnabled( enableEmail: !!(data?.notificationTypes.email ?? true),
NotificationAgentType.EMAIL,
data?.notificationAgents ?? NotificationAgentType.EMAIL
),
pgpKey: data?.pgpKey, pgpKey: data?.pgpKey,
}} }}
validationSchema={UserNotificationsEmailSchema} validationSchema={UserNotificationsEmailSchema}
@ -71,11 +58,13 @@ const UserEmailSettings: React.FC = () => {
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, {
notificationAgents,
pgpKey: values.pgpKey, pgpKey: values.pgpKey,
discordId: data?.discordId, discordId: data?.discordId,
telegramChatId: data?.telegramChatId, telegramChatId: data?.telegramChatId,
telegramSendSilently: data?.telegramSendSilently, telegramSendSilently: data?.telegramSendSilently,
notificationTypes: {
email: values.enableEmail ? ALL_NOTIFICATIONS : 0,
},
}); });
addToast(intl.formatMessage(messages.emailsettingssaved), { addToast(intl.formatMessage(messages.emailsettingssaved), {
appearance: 'success', appearance: 'success',
@ -91,7 +80,7 @@ const UserEmailSettings: React.FC = () => {
} }
}} }}
> >
{({ errors, touched, isSubmitting, isValid, values, setFieldValue }) => { {({ errors, touched, isSubmitting, isValid }) => {
return ( return (
<Form className="section"> <Form className="section">
<div className="form-row"> <div className="form-row">
@ -99,26 +88,7 @@ const UserEmailSettings: React.FC = () => {
{intl.formatMessage(messages.enableEmail)} {intl.formatMessage(messages.enableEmail)}
</label> </label>
<div className="form-input"> <div className="form-input">
<Field <Field type="checkbox" id="enableEmail" name="enableEmail" />
type="checkbox"
id="enableEmail"
name="enableEmail"
checked={hasNotificationAgentEnabled(
NotificationAgentType.EMAIL,
notificationAgents
)}
onChange={() => {
setNotificationAgents(
hasNotificationAgentEnabled(
NotificationAgentType.EMAIL,
notificationAgents
)
? notificationAgents - NotificationAgentType.EMAIL
: notificationAgents + NotificationAgentType.EMAIL
);
setFieldValue('enableEmail', !values.enableEmail);
}}
/>
</div> </div>
</div> </div>
<div className="form-row"> <div className="form-row">

@ -1,20 +1,17 @@
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces'; import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
import {
hasNotificationAgentEnabled,
NotificationAgentType,
} from '../../../../../server/lib/notifications/agenttypes';
import { useUser } from '../../../../hooks/useUser'; import { useUser } from '../../../../hooks/useUser';
import globalMessages from '../../../../i18n/globalMessages'; import globalMessages from '../../../../i18n/globalMessages';
import Button from '../../../Common/Button'; import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner'; import LoadingSpinner from '../../../Common/LoadingSpinner';
import { ALL_NOTIFICATIONS } from '../../../NotificationTypeSelector';
const messages = defineMessages({ const messages = defineMessages({
telegramsettingssaved: 'Telegram notification settings saved successfully!', telegramsettingssaved: 'Telegram notification settings saved successfully!',
@ -32,18 +29,11 @@ const UserTelegramSettings: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const { addToast } = useToasts(); const { addToast } = useToasts();
const router = useRouter(); const router = useRouter();
const [notificationAgents, setNotificationAgents] = useState(0);
const { user } = useUser({ id: Number(router.query.userId) }); const { user } = useUser({ id: Number(router.query.userId) });
const { data, error, revalidate } = useSWR<UserSettingsNotificationsResponse>( const { data, error, revalidate } = useSWR<UserSettingsNotificationsResponse>(
user ? `/api/v1/user/${user?.id}/settings/notifications` : null user ? `/api/v1/user/${user?.id}/settings/notifications` : null
); );
useEffect(() => {
setNotificationAgents(
data?.notificationAgents ?? NotificationAgentType.EMAIL
);
}, [data]);
const UserNotificationsTelegramSchema = Yup.object().shape({ const UserNotificationsTelegramSchema = Yup.object().shape({
telegramChatId: Yup.string() telegramChatId: Yup.string()
.when('enableTelegram', { .when('enableTelegram', {
@ -66,10 +56,7 @@ const UserTelegramSettings: React.FC = () => {
return ( return (
<Formik <Formik
initialValues={{ initialValues={{
enableTelegram: hasNotificationAgentEnabled( enableTelegram: !!data?.notificationTypes.telegram,
NotificationAgentType.TELEGRAM,
data?.notificationAgents ?? NotificationAgentType.EMAIL
),
telegramChatId: data?.telegramChatId, telegramChatId: data?.telegramChatId,
telegramSendSilently: data?.telegramSendSilently, telegramSendSilently: data?.telegramSendSilently,
}} }}
@ -78,11 +65,13 @@ const UserTelegramSettings: React.FC = () => {
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, {
notificationAgents,
pgpKey: data?.pgpKey, pgpKey: data?.pgpKey,
discordId: data?.discordId, discordId: data?.discordId,
telegramChatId: values.telegramChatId, telegramChatId: values.telegramChatId,
telegramSendSilently: values.telegramSendSilently, telegramSendSilently: values.telegramSendSilently,
notificationTypes: {
telegram: values.enableTelegram ? ALL_NOTIFICATIONS : 0,
},
}); });
addToast(intl.formatMessage(messages.telegramsettingssaved), { addToast(intl.formatMessage(messages.telegramsettingssaved), {
appearance: 'success', appearance: 'success',
@ -98,7 +87,7 @@ const UserTelegramSettings: React.FC = () => {
} }
}} }}
> >
{({ errors, touched, isSubmitting, isValid, values, setFieldValue }) => { {({ errors, touched, isSubmitting, isValid }) => {
return ( return (
<Form className="section"> <Form className="section">
<div className="form-row"> <div className="form-row">
@ -110,21 +99,6 @@ const UserTelegramSettings: React.FC = () => {
type="checkbox" type="checkbox"
id="enableTelegram" id="enableTelegram"
name="enableTelegram" name="enableTelegram"
checked={hasNotificationAgentEnabled(
NotificationAgentType.TELEGRAM,
notificationAgents
)}
onChange={() => {
setNotificationAgents(
hasNotificationAgentEnabled(
NotificationAgentType.TELEGRAM,
notificationAgents
)
? notificationAgents - NotificationAgentType.TELEGRAM
: notificationAgents + NotificationAgentType.TELEGRAM
);
setFieldValue('enableTelegram', !values.enableTelegram);
}}
/> />
</div> </div>
</div> </div>

@ -0,0 +1,102 @@
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useRouter } from 'next/router';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
import { useUser } from '../../../../hooks/useUser';
import globalMessages from '../../../../i18n/globalMessages';
import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner';
import { ALL_NOTIFICATIONS } from '../../../NotificationTypeSelector';
const messages = defineMessages({
webpushsettingssaved: 'Web push notification settings saved successfully!',
webpushsettingsfailed: 'Web push notification settings failed to save.',
enableWebPush: 'Enable Notifications',
});
const UserWebPushSettings: React.FC = () => {
const intl = useIntl();
const { addToast } = useToasts();
const router = useRouter();
const { user } = useUser({ id: Number(router.query.userId) });
const { data, error, revalidate } = useSWR<UserSettingsNotificationsResponse>(
user ? `/api/v1/user/${user?.id}/settings/notifications` : null
);
if (!data && !error) {
return <LoadingSpinner />;
}
return (
<Formik
initialValues={{
enableWebPush: !!(data?.notificationTypes.webpush ?? true),
pgpKey: data?.pgpKey,
}}
enableReinitialize
onSubmit={async (values) => {
try {
await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, {
discordId: data?.discordId,
telegramChatId: data?.telegramChatId,
telegramSendSilently: data?.telegramSendSilently,
notificationTypes: {
webpush: values.enableWebPush ? ALL_NOTIFICATIONS : 0,
},
});
addToast(intl.formatMessage(messages.webpushsettingssaved), {
appearance: 'success',
autoDismiss: true,
});
} catch (e) {
addToast(intl.formatMessage(messages.webpushsettingsfailed), {
appearance: 'error',
autoDismiss: true,
});
} finally {
revalidate();
}
}}
>
{({ isSubmitting, isValid }) => {
return (
<Form className="section">
<div className="form-row">
<label htmlFor="enableEmail" className="checkbox-label">
{intl.formatMessage(messages.enableWebPush)}
</label>
<div className="form-input">
<Field
type="checkbox"
id="enableWebPush"
name="enableWebPush"
/>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</Button>
</span>
</div>
</div>
</Form>
);
}}
</Formik>
);
};
export default UserWebPushSettings;

@ -1,4 +1,5 @@
import { AtSymbolIcon } from '@heroicons/react/outline'; import { AtSymbolIcon } from '@heroicons/react/outline';
import { CloudIcon } from '@heroicons/react/solid';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
@ -17,6 +18,7 @@ const messages = defineMessages({
notifications: 'Notifications', notifications: 'Notifications',
notificationsettings: 'Notification Settings', notificationsettings: 'Notification Settings',
email: 'Email', email: 'Email',
webpush: 'Web Push',
toastSettingsSuccess: 'Notification settings saved successfully!', toastSettingsSuccess: 'Notification settings saved successfully!',
toastSettingsFailure: 'Something went wrong while saving settings.', toastSettingsFailure: 'Something went wrong while saving settings.',
}); });
@ -65,6 +67,18 @@ const UserNotificationSettings: React.FC = ({ children }) => {
regex: /\/settings\/notifications\/telegram/, regex: /\/settings\/notifications\/telegram/,
hidden: !data?.telegramEnabled || !data?.telegramBotUsername, hidden: !data?.telegramEnabled || !data?.telegramBotUsername,
}, },
{
text: intl.formatMessage(messages.webpush),
content: (
<span className="flex items-center">
<CloudIcon className="h-4 mr-2" />
{intl.formatMessage(messages.webpush)}
</span>
),
route: '/settings/notifications/webpush',
regex: /\/settings\/notifications\/webpush/,
hidden: !data?.webPushEnabled,
},
]; ];
settingsRoutes.forEach((settingsRoute) => { settingsRoutes.forEach((settingsRoute) => {

@ -18,7 +18,79 @@ export type AvailableLocales =
| 'sv' | 'sv'
| 'zh-TW'; | 'zh-TW';
interface LanguageContextProps { type AvailableLanguageObject = Record<
string,
{ code: AvailableLocales; display: string }
>;
export const availableLanguages: AvailableLanguageObject = {
ca: {
code: 'ca',
display: 'Català',
},
de: {
code: 'de',
display: 'Deutsch',
},
en: {
code: 'en',
display: 'English',
},
es: {
code: 'es',
display: 'Español',
},
fr: {
code: 'fr',
display: 'Français',
},
it: {
code: 'it',
display: 'Italiano',
},
hu: {
code: 'hu',
display: 'Magyar',
},
nl: {
code: 'nl',
display: 'Nederlands',
},
'nb-NO': {
code: 'nb-NO',
display: 'Norsk Bokmål',
},
'pt-BR': {
code: 'pt-BR',
display: 'Português (Brasil)',
},
'pt-PT': {
code: 'pt-PT',
display: 'Português (Portugal)',
},
sv: {
code: 'sv',
display: 'Svenska',
},
ru: {
code: 'ru',
display: 'pусский',
},
sr: {
code: 'sr',
display: 'српски језик‬',
},
ja: {
code: 'ja',
display: '日本語',
},
'zh-TW': {
code: 'zh-TW',
display: '中文(臺灣)',
},
};
export interface LanguageContextProps {
locale: AvailableLocales; locale: AvailableLocales;
children: (locale: string) => ReactNode; children: (locale: string) => ReactNode;
setLocale?: React.Dispatch<React.SetStateAction<AvailableLocales>>; setLocale?: React.Dispatch<React.SetStateAction<AvailableLocales>>;

@ -17,6 +17,8 @@ const defaultSettings = {
originalLanguage: '', originalLanguage: '',
partialRequestsEnabled: true, partialRequestsEnabled: true,
cacheImages: false, cacheImages: false,
vapidPublic: '',
enablePushRegistration: false,
}; };
export const SettingsContext = React.createContext<SettingsContextProps>({ export const SettingsContext = React.createContext<SettingsContextProps>({

@ -1,7 +1,5 @@
import { useContext } from 'react';
import { useSWRInfinite } from 'swr'; import { useSWRInfinite } from 'swr';
import { MediaStatus } from '../../server/constants/media'; import { MediaStatus } from '../../server/constants/media';
import { LanguageContext } from '../context/LanguageContext';
import useSettings from './useSettings'; import useSettings from './useSettings';
export interface BaseSearchResult<T> { export interface BaseSearchResult<T> {
@ -35,7 +33,6 @@ const useDiscover = <T extends BaseMedia, S = Record<string, never>>(
{ hideAvailable = true } = {} { hideAvailable = true } = {}
): DiscoverResult<T, S> => { ): DiscoverResult<T, S> => {
const settings = useSettings(); const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize, isValidating } = useSWRInfinite< const { data, error, size, setSize, isValidating } = useSWRInfinite<
BaseSearchResult<T> & S BaseSearchResult<T> & S
>( >(
@ -46,7 +43,6 @@ const useDiscover = <T extends BaseMedia, S = Record<string, never>>(
const params: Record<string, unknown> = { const params: Record<string, unknown> = {
page: pageIndex + 1, page: pageIndex + 1,
language: locale,
...options, ...options,
}; };

@ -0,0 +1,13 @@
import { useContext } from 'react';
import {
LanguageContext,
LanguageContextProps,
} from '../context/LanguageContext';
const useLocale = (): Omit<LanguageContextProps, 'children'> => {
const languageContext = useContext(LanguageContext);
return languageContext;
};
export default useLocale;

@ -6,6 +6,7 @@ import {
Permission, Permission,
PermissionCheckOptions, PermissionCheckOptions,
} from '../../server/lib/permissions'; } from '../../server/lib/permissions';
import { NotificationAgentKey } from '../../server/lib/settings';
export { Permission, UserType }; export { Permission, UserType };
export type { PermissionCheckOptions }; export type { PermissionCheckOptions };
@ -25,10 +26,14 @@ export interface User {
settings?: UserSettings; settings?: UserSettings;
} }
type NotificationAgentTypes = Record<NotificationAgentKey, number>;
export interface UserSettings { export interface UserSettings {
discordId?: string; discordId?: string;
region?: string; region?: string;
originalLanguage?: string; originalLanguage?: string;
locale?: string;
notificationTypes: Partial<NotificationAgentTypes>;
} }
interface UserHookResponse { interface UserHookResponse {

@ -268,6 +268,10 @@
"components.Settings.Notifications.NotificationsSlack.testsent": "Slack test notification sent!", "components.Settings.Notifications.NotificationsSlack.testsent": "Slack test notification sent!",
"components.Settings.Notifications.NotificationsSlack.validationWebhookUrl": "You must provide a valid URL", "components.Settings.Notifications.NotificationsSlack.validationWebhookUrl": "You must provide a valid URL",
"components.Settings.Notifications.NotificationsSlack.webhookUrl": "Webhook URL", "components.Settings.Notifications.NotificationsSlack.webhookUrl": "Webhook URL",
"components.Settings.Notifications.NotificationsWebPush.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsWebPush.testsent": "Web push test notification sent!",
"components.Settings.Notifications.NotificationsWebPush.webpushsettingsfailed": "Web push notification settings failed to save.",
"components.Settings.Notifications.NotificationsWebPush.webpushsettingssaved": "Web push notification settings saved successfully!",
"components.Settings.Notifications.NotificationsWebhook.agentenabled": "Enable Agent", "components.Settings.Notifications.NotificationsWebhook.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsWebhook.authheader": "Authorization Header", "components.Settings.Notifications.NotificationsWebhook.authheader": "Authorization Header",
"components.Settings.Notifications.NotificationsWebhook.customJson": "JSON Payload", "components.Settings.Notifications.NotificationsWebhook.customJson": "JSON Payload",
@ -599,6 +603,7 @@
"components.Settings.validationHostnameRequired": "You must provide a hostname or IP address", "components.Settings.validationHostnameRequired": "You must provide a hostname or IP address",
"components.Settings.validationPortRequired": "You must provide a valid port number", "components.Settings.validationPortRequired": "You must provide a valid port number",
"components.Settings.webhook": "Webhook", "components.Settings.webhook": "Webhook",
"components.Settings.webpush": "Web Push",
"components.Setup.configureplex": "Configure Plex", "components.Setup.configureplex": "Configure Plex",
"components.Setup.configureservices": "Configure Services", "components.Setup.configureservices": "Configure Services",
"components.Setup.continue": "Continue", "components.Setup.continue": "Continue",
@ -694,6 +699,7 @@
"components.UserProfile.ProfileHeader.userid": "User ID: {userid}", "components.UserProfile.ProfileHeader.userid": "User ID: {userid}",
"components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type", "components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type",
"components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin", "components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin",
"components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language",
"components.UserProfile.UserSettings.UserGeneralSettings.displayName": "Display Name", "components.UserProfile.UserSettings.UserGeneralSettings.displayName": "Display Name",
"components.UserProfile.UserSettings.UserGeneralSettings.enableOverride": "Enable Override", "components.UserProfile.UserSettings.UserGeneralSettings.enableOverride": "Enable Override",
"components.UserProfile.UserSettings.UserGeneralSettings.general": "General", "components.UserProfile.UserSettings.UserGeneralSettings.general": "General",
@ -716,11 +722,12 @@
"components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "Discord notification settings failed to save.", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "Discord notification settings failed to save.",
"components.UserProfile.UserSettings.UserNotificationSettings.discordsettingssaved": "Discord notification settings saved successfully!", "components.UserProfile.UserSettings.UserNotificationSettings.discordsettingssaved": "Discord notification settings saved successfully!",
"components.UserProfile.UserSettings.UserNotificationSettings.email": "Email", "components.UserProfile.UserSettings.UserNotificationSettings.email": "Email",
"components.UserProfile.UserSettings.UserNotificationSettings.emailsettingsfailed": "Email notification settings failed to save.", "components.UserProfile.UserSettings.UserNotificationSettings.emailsettingsfailed": "Web push notification settings failed to save.",
"components.UserProfile.UserSettings.UserNotificationSettings.emailsettingssaved": "Email notification settings saved successfully!", "components.UserProfile.UserSettings.UserNotificationSettings.emailsettingssaved": "Web push notification settings saved successfully!",
"components.UserProfile.UserSettings.UserNotificationSettings.enableDiscord": "Enable Mentions", "components.UserProfile.UserSettings.UserNotificationSettings.enableDiscord": "Enable Mentions",
"components.UserProfile.UserSettings.UserNotificationSettings.enableEmail": "Enable Notifications", "components.UserProfile.UserSettings.UserNotificationSettings.enableEmail": "Enable Notifications",
"components.UserProfile.UserSettings.UserNotificationSettings.enableTelegram": "Enable Notifications", "components.UserProfile.UserSettings.UserNotificationSettings.enableTelegram": "Enable Notifications",
"components.UserProfile.UserSettings.UserNotificationSettings.enableWebPush": "Enable Notifications",
"components.UserProfile.UserSettings.UserNotificationSettings.notifications": "Notifications", "components.UserProfile.UserSettings.UserNotificationSettings.notifications": "Notifications",
"components.UserProfile.UserSettings.UserNotificationSettings.notificationsettings": "Notification Settings", "components.UserProfile.UserSettings.UserNotificationSettings.notificationsettings": "Notification Settings",
"components.UserProfile.UserSettings.UserNotificationSettings.pgpPublicKey": "PGP Public Key", "components.UserProfile.UserSettings.UserNotificationSettings.pgpPublicKey": "PGP Public Key",
@ -736,6 +743,7 @@
"components.UserProfile.UserSettings.UserNotificationSettings.validationDiscordId": "You must provide a valid user ID", "components.UserProfile.UserSettings.UserNotificationSettings.validationDiscordId": "You must provide a valid user ID",
"components.UserProfile.UserSettings.UserNotificationSettings.validationPgpPublicKey": "You must provide a valid PGP public key", "components.UserProfile.UserSettings.UserNotificationSettings.validationPgpPublicKey": "You must provide a valid PGP public key",
"components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramChatId": "You must provide a valid chat ID", "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramChatId": "You must provide a valid chat ID",
"components.UserProfile.UserSettings.UserNotificationSettings.webpush": "Web Push",
"components.UserProfile.UserSettings.UserPasswordChange.confirmpassword": "Confirm Password", "components.UserProfile.UserSettings.UserPasswordChange.confirmpassword": "Confirm Password",
"components.UserProfile.UserSettings.UserPasswordChange.currentpassword": "Current Password", "components.UserProfile.UserSettings.UserPasswordChange.currentpassword": "Current Password",
"components.UserProfile.UserSettings.UserPasswordChange.newpassword": "New Password", "components.UserProfile.UserSettings.UserPasswordChange.newpassword": "New Password",

@ -1,7 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import App, { AppInitialProps, AppProps } from 'next/app'; import App, { AppInitialProps, AppProps } from 'next/app';
import Head from 'next/head'; import Head from 'next/head';
import { parseCookies, setCookie } from 'nookies';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import { ToastProvider } from 'react-toast-notifications'; import { ToastProvider } from 'react-toast-notifications';
@ -9,8 +8,11 @@ import { SWRConfig } from 'swr';
import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces'; import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import LoadingBar from '../components/LoadingBar'; import LoadingBar from '../components/LoadingBar';
import PWAHeader from '../components/PWAHeader';
import ServiceWorkerSetup from '../components/ServiceWorkerSetup';
import StatusChecker from '../components/StatusChacker'; import StatusChecker from '../components/StatusChacker';
import Toast from '../components/Toast'; import Toast from '../components/Toast';
import ToastContainer from '../components/ToastContainer';
import { InteractionProvider } from '../context/InteractionContext'; import { InteractionProvider } from '../context/InteractionContext';
import { AvailableLocales, LanguageContext } from '../context/LanguageContext'; import { AvailableLocales, LanguageContext } from '../context/LanguageContext';
import { SettingsProvider } from '../context/SettingsContext'; import { SettingsProvider } from '../context/SettingsContext';
@ -88,10 +90,6 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
useEffect(() => { useEffect(() => {
loadLocaleData(currentLocale).then(setMessages); loadLocaleData(currentLocale).then(setMessages);
setCookie(null, 'locale', currentLocale, {
path: '/',
maxAge: 60 * 60 * 24 * 365 * 10,
});
}, [currentLocale]); }, [currentLocale]);
if (router.pathname.match(/(login|setup|resetpassword)/)) { if (router.pathname.match(/(login|setup|resetpassword)/)) {
@ -119,15 +117,19 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
<LoadingBar /> <LoadingBar />
<SettingsProvider currentSettings={currentSettings}> <SettingsProvider currentSettings={currentSettings}>
<InteractionProvider> <InteractionProvider>
<ToastProvider components={{ Toast }}> <ToastProvider components={{ Toast, ToastContainer }}>
<Head> <Head>
<title>Overseerr</title> <title>Overseerr</title>
<meta <meta
name="viewport" name="viewport"
content="initial-scale=1, viewport-fit=cover, width=device-width" content="initial-scale=1, viewport-fit=cover, width=device-width"
></meta> ></meta>
<PWAHeader
applicationTitle={currentSettings.applicationTitle}
/>
</Head> </Head>
<StatusChecker /> <StatusChecker />
<ServiceWorkerSetup />
<UserContext initialUser={user}>{component}</UserContext> <UserContext initialUser={user}>{component}</UserContext>
</ToastProvider> </ToastProvider>
</InteractionProvider> </InteractionProvider>
@ -140,7 +142,7 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
CoreApp.getInitialProps = async (initialProps) => { CoreApp.getInitialProps = async (initialProps) => {
const { ctx, router } = initialProps; const { ctx, router } = initialProps;
let user = undefined; let user: User | undefined = undefined;
let currentSettings: PublicSettingsResponse = { let currentSettings: PublicSettingsResponse = {
initialized: false, initialized: false,
applicationTitle: '', applicationTitle: '',
@ -152,10 +154,10 @@ CoreApp.getInitialProps = async (initialProps) => {
originalLanguage: '', originalLanguage: '',
partialRequestsEnabled: true, partialRequestsEnabled: true,
cacheImages: false, cacheImages: false,
vapidPublic: '',
enablePushRegistration: false,
}; };
let locale = 'en';
if (ctx.res) { if (ctx.res) {
// Check if app is initialized and redirect if necessary // Check if app is initialized and redirect if necessary
const response = await axios.get<PublicSettingsResponse>( const response = await axios.get<PublicSettingsResponse>(
@ -200,12 +202,6 @@ CoreApp.getInitialProps = async (initialProps) => {
} }
} }
} }
const cookies = parseCookies(ctx);
if (cookies.locale) {
locale = cookies.locale;
}
} }
// Run the default getInitialProps for the main nextjs initialProps // Run the default getInitialProps for the main nextjs initialProps
@ -213,6 +209,8 @@ CoreApp.getInitialProps = async (initialProps) => {
initialProps initialProps
); );
const locale = user?.settings?.locale ?? 'en';
const messages = await loadLocaleData(locale as AvailableLocales); const messages = await loadLocaleData(locale as AvailableLocales);
return { ...appInitialProps, user, messages, locale, currentSettings }; return { ...appInitialProps, user, messages, locale, currentSettings };

@ -22,163 +22,6 @@ class MyDocument extends Document {
<Html> <Html>
<Head> <Head>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" /> <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-16x16.png"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2048-2732.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2732-2048.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1668-2388.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2388-1668.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1536-2048.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2048-1536.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1668-2224.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2224-1668.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1620-2160.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2160-1620.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1284-2778.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2778-1284.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1170-2532.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2532-1170.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1125-2436.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2436-1125.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1242-2688.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2688-1242.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-828-1792.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1792-828.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1242-2208.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-2208-1242.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-750-1334.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1334-750.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-640-1136.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/apple-splash-1136-640.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<link
rel="manifest"
href="/site.webmanifest"
crossOrigin="use-credentials"
/>
</Head> </Head>
<body> <body>
<Main /> <Main />

@ -0,0 +1,17 @@
import { NextPage } from 'next';
import React from 'react';
import UserSettings from '../../../../components/UserProfile/UserSettings';
import UserNotificationSettings from '../../../../components/UserProfile/UserSettings/UserNotificationSettings';
import UserWebPushSettings from '../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush';
const WebPushProfileNotificationsPage: NextPage = () => {
return (
<UserSettings>
<UserNotificationSettings>
<UserWebPushSettings />
</UserNotificationSettings>
</UserSettings>
);
};
export default WebPushProfileNotificationsPage;

@ -0,0 +1,17 @@
import { NextPage } from 'next';
import React from 'react';
import NotificationsWebPush from '../../../components/Settings/Notifications/NotificationsWebPush';
import SettingsLayout from '../../../components/Settings/SettingsLayout';
import SettingsNotifications from '../../../components/Settings/SettingsNotifications';
const NotificationsWebPushPage: NextPage = () => {
return (
<SettingsLayout>
<SettingsNotifications>
<NotificationsWebPush />
</SettingsNotifications>
</SettingsLayout>
);
};
export default NotificationsWebPushPage;

@ -0,0 +1,20 @@
import { NextPage } from 'next';
import React from 'react';
import UserSettings from '../../../../../components/UserProfile/UserSettings';
import UserNotificationSettings from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings';
import UserWebPushSettings from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush';
import useRouteGuard from '../../../../../hooks/useRouteGuard';
import { Permission } from '../../../../../hooks/useUser';
const WebPushNotificationsPage: NextPage = () => {
useRouteGuard(Permission.MANAGE_USERS);
return (
<UserSettings>
<UserNotificationSettings>
<UserWebPushSettings />
</UserNotificationSettings>
</UserSettings>
);
};
export default WebPushNotificationsPage;

@ -402,3 +402,9 @@ input[type='search']::-webkit-search-cancel-button {
@apply text-white border-none; @apply text-white border-none;
box-shadow: none; box-shadow: none;
} }
@media all and (display-mode: browser) {
.pwa-only {
@apply hidden;
}
}

@ -2443,6 +2443,13 @@
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f"
integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ== integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==
"@types/web-push@^3.3.0":
version "3.3.0"
resolved "https://registry.yarnpkg.com/@types/web-push/-/web-push-3.3.0.tgz#459eb722c9585b84a149e7020606d4f65f64f0ca"
integrity sha512-QHEQCPrVy1JZtZK0cA8DHT2MhuCJNyI3m+DzuOTSGa56VM6g2bjdD+hMp8A/2Ca9w0GfmdcStrLgfXAUKKlvJg==
dependencies:
"@types/node" "*"
"@types/xml2js@^0.4.8": "@types/xml2js@^0.4.8":
version "0.4.8" version "0.4.8"
resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.8.tgz#84c120c864a5976d0b5cf2f930a75d850fc2b03a" resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.8.tgz#84c120c864a5976d0b5cf2f930a75d850fc2b03a"
@ -2947,7 +2954,7 @@ asap@^2.0.0, asap@~2.0.3:
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=
asn1.js@^5.0.0, asn1.js@^5.2.0: asn1.js@^5.0.0, asn1.js@^5.2.0, asn1.js@^5.3.0:
version "5.4.1" version "5.4.1"
resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07"
integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==
@ -3461,6 +3468,11 @@ browserslist@^4.16.3:
escalade "^3.1.1" escalade "^3.1.1"
node-releases "^1.1.70" node-releases "^1.1.70"
buffer-equal-constant-time@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=
buffer-from@^1.0.0: buffer-from@^1.0.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
@ -5261,6 +5273,13 @@ ecc-jsbn@~0.1.1:
jsbn "~0.1.0" jsbn "~0.1.0"
safer-buffer "^2.1.0" safer-buffer "^2.1.0"
ecdsa-sig-formatter@1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
dependencies:
safe-buffer "^5.0.1"
editor@~1.0.0: editor@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/editor/-/editor-1.0.0.tgz#60c7f87bd62bcc6a894fa8ccd6afb7823a24f742" resolved "https://registry.yarnpkg.com/editor/-/editor-1.0.0.tgz#60c7f87bd62bcc6a894fa8ccd6afb7823a24f742"
@ -6989,6 +7008,13 @@ http-signature@~1.2.0:
jsprim "^1.2.2" jsprim "^1.2.2"
sshpk "^1.7.0" sshpk "^1.7.0"
http_ece@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/http_ece/-/http_ece-1.1.0.tgz#74780c6eb32d8ddfe9e36a83abcd81fe0cd4fb75"
integrity sha512-bptAfCDdPJxOs5zYSe7Y3lpr772s1G346R4Td5LgRUeCwIGpCGDUTJxRrhTNcAXbx37spge0kWEIH7QAYWNTlA==
dependencies:
urlsafe-base64 "~1.0.0"
https-browserify@1.0.0, https-browserify@^1.0.0: https-browserify@1.0.0, https-browserify@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
@ -8005,6 +8031,23 @@ juice@^7.0.0:
slick "^1.12.2" slick "^1.12.2"
web-resource-inliner "^5.0.0" web-resource-inliner "^5.0.0"
jwa@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc"
integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==
dependencies:
buffer-equal-constant-time "1.0.1"
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jws@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4"
integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==
dependencies:
jwa "^2.0.0"
safe-buffer "^5.0.1"
keyv@^3.0.0: keyv@^3.0.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
@ -13763,6 +13806,11 @@ url@^0.11.0:
punycode "1.3.2" punycode "1.3.2"
querystring "0.2.0" querystring "0.2.0"
urlsafe-base64@^1.0.0, urlsafe-base64@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/urlsafe-base64/-/urlsafe-base64-1.0.0.tgz#23f89069a6c62f46cf3a1d3b00169cefb90be0c6"
integrity sha1-I/iQaabGL0bPOh07ABac77kL4MY=
use-subscription@1.5.1: use-subscription@1.5.1:
version "1.5.1" version "1.5.1"
resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.5.1.tgz#73501107f02fad84c6dd57965beb0b75c68c42d1" resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.5.1.tgz#73501107f02fad84c6dd57965beb0b75c68c42d1"
@ -13930,6 +13978,18 @@ wcwidth@^1.0.0:
dependencies: dependencies:
defaults "^1.0.3" defaults "^1.0.3"
web-push@^3.4.4:
version "3.4.4"
resolved "https://registry.yarnpkg.com/web-push/-/web-push-3.4.4.tgz#b11523ada0f4b8c2481f65d1d059acd45ba27ca0"
integrity sha512-tB0F+ccobsfw5jTWBinWJKyd/YdCdRbKj+CFSnsJeEgFYysOULvWFYyeCxn9KuQvG/3UF1t3cTAcJzBec5LCWA==
dependencies:
asn1.js "^5.3.0"
http_ece "1.1.0"
https-proxy-agent "^5.0.0"
jws "^4.0.0"
minimist "^1.2.5"
urlsafe-base64 "^1.0.0"
web-resource-inliner@^5.0.0: web-resource-inliner@^5.0.0:
version "5.0.0" version "5.0.0"
resolved "https://registry.yarnpkg.com/web-resource-inliner/-/web-resource-inliner-5.0.0.tgz#ac30db8096931f20a7c1b3ade54ff444e2e20f7b" resolved "https://registry.yarnpkg.com/web-resource-inliner/-/web-resource-inliner-5.0.0.tgz#ac30db8096931f20a7c1b3ade54ff444e2e20f7b"

Loading…
Cancel
Save