diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 000000000..a162593a2 --- /dev/null +++ b/.github/workflows/preview.yml @@ -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 diff --git a/overseerr-api.yml b/overseerr-api.yml index 08bf1b5ca..e8258adfc 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1128,6 +1128,15 @@ components: properties: webhookUrl: type: string + WebPushSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 WebhookSettings: type: object properties: @@ -2581,6 +2590,52 @@ paths: responses: '204': 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: get: summary: Get webhook notification settings @@ -2903,6 +2958,32 @@ paths: type: array items: $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}: get: summary: Get user by ID diff --git a/package.json b/package.json index 2c25865aa..a8df8fef5 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "swr": "^0.5.5", "typeorm": "^0.2.32", "uuid": "^8.3.2", + "web-push": "^3.4.4", "winston": "^3.3.3", "winston-daily-rotate-file": "^4.5.2", "xml2js": "^0.4.23", @@ -107,6 +108,7 @@ "@types/secure-random-password": "^0.2.0", "@types/swagger-ui-express": "^4.1.2", "@types/uuid": "^8.3.0", + "@types/web-push": "^3.3.0", "@types/xml2js": "^0.4.8", "@types/yamljs": "^0.2.31", "@types/yup": "^0.29.11", diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index 692f01a85..08880f828 100644 Binary files a/public/android-chrome-192x192.png and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-192x192_maskable.png b/public/android-chrome-192x192_maskable.png new file mode 100644 index 000000000..de21f9148 Binary files /dev/null and b/public/android-chrome-192x192_maskable.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png index 34d1f9e1e..23eb4a8e0 100644 Binary files a/public/android-chrome-512x512.png and b/public/android-chrome-512x512.png differ diff --git a/public/android-chrome-512x512_maskable.png b/public/android-chrome-512x512_maskable.png new file mode 100644 index 000000000..82afcee5e Binary files /dev/null and b/public/android-chrome-512x512_maskable.png differ diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 000000000..01658360f --- /dev/null +++ b/public/offline.html @@ -0,0 +1,69 @@ + + + + + + + + You are offline + + + + + +

You are offline

+ + + + + + + diff --git a/public/preview.jpg b/public/preview.jpg index 946ef07a9..e393f1667 100644 Binary files a/public/preview.jpg and b/public/preview.jpg differ diff --git a/public/site.webmanifest b/public/site.webmanifest index 6cd906115..3465bc838 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -7,16 +7,28 @@ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", + "purpose": "any" + }, + { + "src": "/android-chrome-192x192_maskable.png", + "sizes": "192x192", + "type": "image/png", "purpose": "maskable" }, { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", + "purpose": "any" + }, + { + "src": "/android-chrome-512x512_maskable.png", + "sizes": "512x512", + "type": "image/png", "purpose": "maskable" } ], - "theme_color": "#2d3748", - "background_color": "#2d3748", + "theme_color": "#1f2937", + "background_color": "#1f2937", "display": "standalone" } diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 000000000..d6672e609 --- /dev/null +++ b/public/sw.js @@ -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); diff --git a/server/entity/User.ts b/server/entity/User.ts index 25b57f716..5e83dd068 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -29,6 +29,7 @@ import { getSettings } from '../lib/settings'; import logger from '../logger'; import { MediaRequest } from './MediaRequest'; import SeasonRequest from './SeasonRequest'; +import { UserPushSubscription } from './UserPushSubscription'; import { UserSettings } from './UserSettings'; @Entity() @@ -105,6 +106,9 @@ export class User { }) public settings?: UserSettings; + @OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user) + public pushSubscriptions: UserPushSubscription[]; + @CreateDateColumn() public createdAt: Date; diff --git a/server/entity/UserPushSubscription.ts b/server/entity/UserPushSubscription.ts new file mode 100644 index 000000000..6389ea0b8 --- /dev/null +++ b/server/entity/UserPushSubscription.ts @@ -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) { + Object.assign(this, init); + } +} diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 023a1bde7..e710b0a29 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -5,12 +5,15 @@ import { OneToOne, PrimaryGeneratedColumn, } from 'typeorm'; -import { - hasNotificationAgentEnabled, - NotificationAgentType, -} from '../lib/notifications/agenttypes'; +import { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces'; +import { hasNotificationType, Notification } from '../lib/notifications'; +import { NotificationAgentKey } from '../lib/settings'; import { User } from './User'; +export const ALL_NOTIFICATIONS = Object.values(Notification) + .filter((v) => !isNaN(Number(v))) + .reduce((a, v) => a + Number(v), 0); + @Entity() export class UserSettings { constructor(init?: Partial) { @@ -24,15 +27,15 @@ export class UserSettings { @JoinColumn() public user: User; + @Column({ default: 'en' }) + public locale?: string; + @Column({ nullable: true }) public region?: string; @Column({ nullable: true }) public originalLanguage?: string; - @Column({ type: 'integer', default: NotificationAgentType.EMAIL }) - public notificationAgents = NotificationAgentType.EMAIL; - @Column({ nullable: true }) public pgpKey?: string; @@ -45,7 +48,63 @@ export class UserSettings { @Column({ nullable: true }) public telegramSendSilently?: boolean; - public hasNotificationAgentEnabled(agent: NotificationAgentType): boolean { - return !!hasNotificationAgentEnabled(agent, this.notificationAgents); + @Column({ + type: 'text', + nullable: true, + transformer: { + from: (value: string | null): Partial => { + 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; + + // 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): 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; + + public hasNotificationType(key: NotificationAgentKey, type: Notification) { + return hasNotificationType(type, this.notificationTypes[key] ?? 0); } } diff --git a/server/index.ts b/server/index.ts index 3cfd0dba3..749b13dcd 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,30 +1,31 @@ -import express, { Request, Response, NextFunction } from 'express'; -import next from 'next'; -import path from 'path'; -import { createConnection, getRepository } from 'typeorm'; -import routes from './routes'; +import { getClientIp } from '@supercharge/request-ip'; import bodyParser from 'body-parser'; +import { TypeormStore } from 'connect-typeorm/out'; import cookieParser from 'cookie-parser'; import csurf from 'csurf'; -import session, { Store } from 'express-session'; -import { TypeormStore } from 'connect-typeorm/out'; -import YAML from 'yamljs'; -import swaggerUi from 'swagger-ui-express'; +import express, { NextFunction, Request, Response } from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; +import session, { Store } from 'express-session'; +import next from 'next'; +import path from 'path'; +import swaggerUi from 'swagger-ui-express'; +import { createConnection, getRepository } from 'typeorm'; +import YAML from 'yamljs'; import { Session } from './entity/Session'; -import { getSettings } from './lib/settings'; -import logger from './logger'; import { startJobs } from './job/schedule'; import notificationManager from './lib/notifications'; import DiscordAgent from './lib/notifications/agents/discord'; import EmailAgent from './lib/notifications/agents/email'; -import TelegramAgent from './lib/notifications/agents/telegram'; -import { getAppVersion } from './utils/appVersion'; -import SlackAgent from './lib/notifications/agents/slack'; -import PushoverAgent from './lib/notifications/agents/pushover'; -import WebhookAgent from './lib/notifications/agents/webhook'; -import { getClientIp } from '@supercharge/request-ip'; import PushbulletAgent from './lib/notifications/agents/pushbullet'; +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 WebPushAgent from './lib/notifications/agents/webpush'; +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'); @@ -57,6 +58,7 @@ app new SlackAgent(), new TelegramAgent(), new WebhookAgent(), + new WebPushAgent(), ]); // Start Jobs diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 7c40c6db8..d718a9565 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -30,6 +30,8 @@ export interface PublicSettingsResponse { originalLanguage: string; partialRequestsEnabled: boolean; cacheImages: boolean; + vapidPublic: string; + enablePushRegistration: boolean; } export interface CacheItem { diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 006facf00..8fb6ae87d 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -1,5 +1,8 @@ +import { NotificationAgentKey } from '../../lib/settings'; + export interface UserSettingsGeneralResponse { username?: string; + locale?: string; region?: string; originalLanguage?: string; movieQuotaLimit?: number; @@ -12,8 +15,8 @@ export interface UserSettingsGeneralResponse { globalTvQuotaDays?: number; } +export type NotificationAgentTypes = Record; export interface UserSettingsNotificationsResponse { - notificationAgents: number; emailEnabled?: boolean; pgpKey?: string; discordEnabled?: boolean; @@ -22,4 +25,6 @@ export interface UserSettingsNotificationsResponse { telegramBotUsername?: string; telegramChatId?: string; telegramSendSilently?: boolean; + webPushEnabled?: boolean; + notificationTypes: Partial; } diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index c04b4948e..209ac6eb9 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -4,8 +4,11 @@ import { hasNotificationType, Notification } from '..'; import { User } from '../../../entity/User'; import logger from '../../../logger'; import { Permission } from '../../permissions'; -import { getSettings, NotificationAgentDiscord } from '../../settings'; -import { NotificationAgentType } from '../agenttypes'; +import { + getSettings, + NotificationAgentDiscord, + NotificationAgentKey, +} from '../../settings'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; enum EmbedColors { @@ -227,8 +230,9 @@ class DiscordAgent if (payload.notifyUser) { // Mention user who submitted the request if ( - payload.notifyUser.settings?.hasNotificationAgentEnabled( - NotificationAgentType.DISCORD + payload.notifyUser.settings?.hasNotificationType( + NotificationAgentKey.DISCORD, + type ) && payload.notifyUser.settings?.discordId ) { @@ -243,8 +247,9 @@ class DiscordAgent .filter( (user) => user.hasPermission(Permission.MANAGE_REQUESTS) && - user.settings?.hasNotificationAgentEnabled( - NotificationAgentType.DISCORD + user.settings?.hasNotificationType( + NotificationAgentKey.DISCORD, + type ) && user.settings?.discordId ) diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 4d00eb6f2..d3f341862 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -7,8 +7,11 @@ import { User } from '../../../entity/User'; import logger from '../../../logger'; import PreparedEmail from '../../email'; import { Permission } from '../../permissions'; -import { getSettings, NotificationAgentEmail } from '../../settings'; -import { NotificationAgentType } from '../agenttypes'; +import { + getSettings, + NotificationAgentEmail, + NotificationAgentKey, +} from '../../settings'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; class EmailAgent @@ -152,9 +155,13 @@ class EmailAgent // Send notification to the user who submitted the request if ( !payload.notifyUser.settings || - payload.notifyUser.settings.hasNotificationAgentEnabled( - NotificationAgentType.EMAIL - ) + // Check if user has email notifications enabled and fallback to true if undefined + // since email should default to true + (payload.notifyUser.settings.hasNotificationType( + NotificationAgentKey.EMAIL, + type + ) ?? + true) ) { logger.debug('Sending email notification', { label: 'Notifications', @@ -194,9 +201,13 @@ class EmailAgent (user) => user.hasPermission(Permission.MANAGE_REQUESTS) && (!user.settings || - user.settings.hasNotificationAgentEnabled( - NotificationAgentType.EMAIL - )) + // Check if user has email notifications enabled and fallback to true if undefined + // since email should default to true + (user.settings.hasNotificationType( + NotificationAgentKey.EMAIL, + type + ) ?? + true)) ) .map(async (user) => { logger.debug('Sending email notification', { diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index 894a77262..a97bbb6fe 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -2,8 +2,11 @@ import axios from 'axios'; import { hasNotificationType, Notification } from '..'; import { MediaType } from '../../../constants/media'; import logger from '../../../logger'; -import { getSettings, NotificationAgentTelegram } from '../../settings'; -import { NotificationAgentType } from '../agenttypes'; +import { + getSettings, + NotificationAgentKey, + NotificationAgentTelegram, +} from '../../settings'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; interface TelegramMessagePayload { @@ -198,8 +201,9 @@ class TelegramAgent if ( payload.notifyUser && - payload.notifyUser.settings?.hasNotificationAgentEnabled( - NotificationAgentType.TELEGRAM + payload.notifyUser.settings?.hasNotificationType( + NotificationAgentKey.TELEGRAM, + type ) && payload.notifyUser.settings?.telegramChatId && payload.notifyUser.settings?.telegramChatId !== diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts new file mode 100644 index 000000000..fb3376701 --- /dev/null +++ b/server/lib/notifications/agents/webpush.ts @@ -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 + 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 { + 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; diff --git a/server/lib/notifications/agenttypes.ts b/server/lib/notifications/agenttypes.ts deleted file mode 100644 index 9e0d79aa8..000000000 --- a/server/lib/notifications/agenttypes.ts +++ /dev/null @@ -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); -}; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 290d40406..c2ec9b366 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import { merge } from 'lodash'; import path from 'path'; import { v4 as uuidv4 } from 'uuid'; +import webpush from 'web-push'; import { Permission } from './permissions'; export interface Library { @@ -101,6 +102,8 @@ interface FullPublicSettings extends PublicSettings { originalLanguage: string; partialRequestsEnabled: boolean; cacheImages: boolean; + vapidPublic: string; + enablePushRegistration: boolean; } 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 { discord: NotificationAgentDiscord; email: NotificationAgentEmail; @@ -176,6 +190,7 @@ interface NotificationAgents { slack: NotificationAgentSlack; telegram: NotificationAgentTelegram; webhook: NotificationAgentWebhook; + webpush: NotificationAgentConfig; } interface NotificationSettings { @@ -184,6 +199,8 @@ interface NotificationSettings { interface AllSettings { clientId: string; + vapidPublic: string; + vapidPrivate: string; main: MainSettings; plex: PlexSettings; radarr: RadarrSettings[]; @@ -202,6 +219,8 @@ class Settings { constructor(initialSettings?: AllSettings) { this.data = { clientId: uuidv4(), + vapidPrivate: '', + vapidPublic: '', main: { apiKey: '', applicationTitle: 'Overseerr', @@ -298,6 +317,11 @@ class Settings { 'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i', }, }, + webpush: { + enabled: false, + types: 0, + options: {}, + }, }, }, }; @@ -366,6 +390,8 @@ class Settings { originalLanguage: this.data.main.originalLanguage, partialRequestsEnabled: this.data.main.partialRequestsEnabled, 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; } + get vapidPublic(): string { + this.generateVapidKeys(); + + return this.data.vapidPublic; + } + + get vapidPrivate(): string { + this.generateVapidKeys(); + + return this.data.vapidPrivate; + } + public regenerateApiKey(): MainSettings { this.main.apiKey = this.generateApiKey(); this.save(); @@ -396,6 +434,15 @@ class Settings { 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 * diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 6d36bb2f9..64d693aec 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -28,6 +28,7 @@ export const checkUser: Middleware = async (req, _res, next) => { if (user) { req.user = user; + req.locale = user.settings?.locale; } } next(); diff --git a/server/migration/1618912653565-CreateUserPushSubscriptions.ts b/server/migration/1618912653565-CreateUserPushSubscriptions.ts new file mode 100644 index 000000000..90ea0d3f9 --- /dev/null +++ b/server/migration/1618912653565-CreateUserPushSubscriptions.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserPushSubscriptions1618912653565 + implements MigrationInterface { + name = 'CreateUserPushSubscriptions1618912653565'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/server/migration/1619239659754-AddUserSettingsLocale.ts b/server/migration/1619239659754-AddUserSettingsLocale.ts new file mode 100644 index 000000000..9f8412a53 --- /dev/null +++ b/server/migration/1619239659754-AddUserSettingsLocale.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserSettingsLocale1619239659754 implements MigrationInterface { + name = 'AddUserSettingsLocale1619239659754'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts new file mode 100644 index 000000000..111bdd4e3 --- /dev/null +++ b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserSettingsNotificationTypes1619339817343 + implements MigrationInterface { + name = 'AddUserSettingsNotificationTypes1619339817343'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/server/routes/collection.ts b/server/routes/collection.ts index 75f1a455f..8ffbb51c9 100644 --- a/server/routes/collection.ts +++ b/server/routes/collection.ts @@ -11,7 +11,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => { try { const collection = await tmdb.getCollection({ collectionId: Number(req.params.id), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 3e690c8e5..dd3a9fa66 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,16 +1,16 @@ import { Router } from 'express'; -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 { getSettings } from '../lib/settings'; -import { User } from '../entity/User'; -import { mapProductionCompany } from '../models/Movie'; -import { mapNetwork } from '../models/Tv'; -import logger from '../logger'; import { sortBy } from 'lodash'; +import TheMovieDb from '../api/themoviedb'; +import { MediaType } from '../constants/media'; +import Media from '../entity/Media'; +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 { mapMovieResult, mapPersonResult, mapTvResult } from '../models/Search'; +import { mapNetwork } from '../models/Tv'; +import { isMovie, isPerson } from '../utils/typeHelpers'; const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => { const settings = getSettings(); @@ -42,7 +42,7 @@ discoverRoutes.get('/movies', async (req, res) => { const data = await tmdb.getDiscoverMovies({ 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, studio: req.query.studio ? Number(req.query.studio) : undefined, }); @@ -83,7 +83,7 @@ discoverRoutes.get<{ language: string }>( const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), originalLanguage: req.params.language, }); @@ -115,7 +115,7 @@ discoverRoutes.get<{ genreId: string }>( const tmdb = createTmdbWithRegionLanaguage(req.user); const genres = await tmdb.getMovieGenres({ - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const genre = genres.find( @@ -128,7 +128,7 @@ discoverRoutes.get<{ genreId: string }>( const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), genre: Number(req.params.genreId), }); @@ -164,7 +164,7 @@ discoverRoutes.get<{ studioId: string }>( const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), studio: Number(req.params.studioId), }); @@ -204,7 +204,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res) => { const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), primaryReleaseDateGte: date, }); @@ -232,7 +232,7 @@ discoverRoutes.get('/tv', async (req, res) => { const data = await tmdb.getDiscoverTv({ 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, network: req.query.network ? Number(req.query.network) : undefined, }); @@ -273,7 +273,7 @@ discoverRoutes.get<{ language: string }>( const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), originalLanguage: req.params.language, }); @@ -304,7 +304,7 @@ discoverRoutes.get<{ genreId: string }>( const tmdb = createTmdbWithRegionLanaguage(req.user); const genres = await tmdb.getTvGenres({ - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const genre = genres.find( @@ -317,7 +317,7 @@ discoverRoutes.get<{ genreId: string }>( const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), genre: Number(req.params.genreId), }); @@ -352,7 +352,7 @@ discoverRoutes.get<{ networkId: string }>( const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), network: Number(req.params.networkId), }); @@ -392,7 +392,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res) => { const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), firstAirDateGte: date, }); @@ -420,7 +420,7 @@ discoverRoutes.get('/trending', async (req, res) => { const data = await tmdb.getAllTrending({ page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( @@ -461,7 +461,7 @@ discoverRoutes.get<{ keywordId: string }>( const data = await tmdb.getMoviesByKeyword({ keywordId: Number(req.params.keywordId), page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( @@ -494,7 +494,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( const mappedGenres: GenreSliderItem[] = []; const genres = await tmdb.getMovieGenres({ - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); await Promise.all( @@ -535,7 +535,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( const mappedGenres: GenreSliderItem[] = []; const genres = await tmdb.getTvGenres({ - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); await Promise.all( diff --git a/server/routes/index.ts b/server/routes/index.ts index 72b98c8f7..330af51b9 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -138,7 +138,7 @@ router.get('/genres/movie', isAuthenticated(), async (req, res) => { const tmdb = new TheMovieDb(); 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); @@ -148,7 +148,7 @@ router.get('/genres/tv', isAuthenticated(), async (req, res) => { const tmdb = new TheMovieDb(); 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); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index cadaf5a7e..d871652a4 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -1,11 +1,11 @@ import { Router } from 'express'; +import RottenTomatoes from '../api/rottentomatoes'; import TheMovieDb from '../api/themoviedb'; +import { MediaType } from '../constants/media'; +import Media from '../entity/Media'; +import logger from '../logger'; import { mapMovieDetails } from '../models/Movie'; import { mapMovieResult } from '../models/Search'; -import Media from '../entity/Media'; -import RottenTomatoes from '../api/rottentomatoes'; -import logger from '../logger'; -import { MediaType } from '../constants/media'; const movieRoutes = Router(); @@ -15,7 +15,7 @@ movieRoutes.get('/:id', async (req, res, next) => { try { const tmdbMovie = await tmdb.getMovie({ 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); @@ -36,7 +36,7 @@ movieRoutes.get('/:id/recommendations', async (req, res) => { const results = await tmdb.getMovieRecommendations({ movieId: Number(req.params.id), page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( @@ -64,7 +64,7 @@ movieRoutes.get('/:id/similar', async (req, res) => { const results = await tmdb.getMovieSimilar({ movieId: Number(req.params.id), page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( diff --git a/server/routes/person.ts b/server/routes/person.ts index 7b8d90c4f..e18e55c84 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -16,7 +16,7 @@ personRoutes.get('/:id', async (req, res, next) => { try { const person = await tmdb.getPerson({ 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)); } catch (e) { @@ -30,7 +30,7 @@ personRoutes.get('/:id/combined_credits', async (req, res) => { const combinedCredits = await tmdb.getPersonCombinedCredits({ personId: Number(req.params.id), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const castMedia = await Media.getRelatedMedia( diff --git a/server/routes/search.ts b/server/routes/search.ts index 622e54693..c843e78c3 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import TheMovieDb from '../api/themoviedb'; -import { mapSearchResults } from '../models/Search'; import Media from '../entity/Media'; +import { mapSearchResults } from '../models/Search'; const searchRoutes = Router(); @@ -11,7 +11,7 @@ searchRoutes.get('/', async (req, res) => { const results = await tmdb.searchMulti({ query: req.query.query as string, page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( diff --git a/server/routes/service.ts b/server/routes/service.ts index 51bbc4e31..862ab3748 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -191,7 +191,7 @@ serviceRoutes.get<{ tmdbId: string }>( try { const tv = await tmdb.getTvShow({ 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); diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index 739b3981d..a9a67084b 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -7,6 +7,7 @@ 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 WebPushAgent from '../../lib/notifications/agents/webpush'; import { getSettings } from '../../lib/settings'; const notificationRoutes = Router(); @@ -215,6 +216,40 @@ notificationRoutes.post('/email/test', (req, res, next) => { 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) => { const settings = getSettings(); diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 1ddf1f80c..043e610f7 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -1,11 +1,11 @@ import { Router } from 'express'; -import TheMovieDb from '../api/themoviedb'; -import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv'; -import { mapTvResult } from '../models/Search'; -import Media from '../entity/Media'; import RottenTomatoes from '../api/rottentomatoes'; -import logger from '../logger'; +import TheMovieDb from '../api/themoviedb'; import { MediaType } from '../constants/media'; +import Media from '../entity/Media'; +import logger from '../logger'; +import { mapTvResult } from '../models/Search'; +import { mapSeasonWithEpisodes, mapTvDetails } from '../models/Tv'; const tvRoutes = Router(); @@ -14,7 +14,7 @@ tvRoutes.get('/:id', async (req, res, next) => { try { const tv = await tmdb.getTvShow({ 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); @@ -35,7 +35,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res) => { const season = await tmdb.getTvSeason({ tvId: Number(req.params.id), 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)); @@ -47,7 +47,7 @@ tvRoutes.get('/:id/recommendations', async (req, res) => { const results = await tmdb.getTvRecommendations({ tvId: Number(req.params.id), page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( @@ -75,7 +75,7 @@ tvRoutes.get('/:id/similar', async (req, res) => { const results = await tmdb.getTvSimilar({ tvId: Number(req.params.id), page: Number(req.query.page), - language: req.query.language as string, + language: req.locale ?? (req.query.language as string), }); const media = await Media.getRelatedMedia( diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index a0dab71c8..60d5c33e4 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -5,6 +5,7 @@ import PlexTvAPI from '../../api/plextv'; import { UserType } from '../../constants/user'; import { MediaRequest } from '../../entity/MediaRequest'; import { User } from '../../entity/User'; +import { UserPushSubscription } from '../../entity/UserPushSubscription'; import { QuotaResponse, 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) => { try { const userRepository = getRepository(User); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index f85ef1797..2c2cc7648 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -7,7 +7,6 @@ import { UserSettingsGeneralResponse, UserSettingsNotificationsResponse, } from '../../interfaces/api/userSettingsInterfaces'; -import { NotificationAgentType } from '../../lib/notifications/agenttypes'; import { Permission } from '../../lib/permissions'; import { getSettings } from '../../lib/settings'; import logger from '../../logger'; @@ -52,6 +51,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( return res.status(200).json({ username: user.username, + locale: user.settings?.locale, region: user.settings?.region, originalLanguage: user.settings?.originalLanguage, movieQuotaLimit: user.movieQuotaLimit, @@ -109,17 +109,24 @@ userSettingsRoutes.post< if (!user.settings) { user.settings = new UserSettings({ user: req.user, + locale: req.body.locale, region: req.body.region, originalLanguage: req.body.originalLanguage, }); } 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; } 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) { next({ status: 500, message: e.message }); } @@ -243,8 +250,6 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( } return res.status(200).json({ - notificationAgents: - user.settings?.notificationAgents ?? NotificationAgentType.EMAIL, emailEnabled: settings?.notifications.agents.email.enabled, pgpKey: user.settings?.pgpKey, discordEnabled: settings?.notifications.agents.discord.enabled, @@ -254,6 +259,8 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( settings?.notifications.agents.telegram.options.botUsername, telegramChatId: user.settings?.telegramChatId, telegramSendSilently: user?.settings?.telegramSendSilently, + webPushEnabled: settings?.notifications.agents.webpush.enabled, + notificationTypes: user.settings?.notificationTypes ?? {}, }); } catch (e) { next({ status: 500, message: e.message }); @@ -287,30 +294,32 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( if (!user.settings) { user.settings = new UserSettings({ user: req.user, - notificationAgents: - req.body.notificationAgents ?? NotificationAgentType.EMAIL, pgpKey: req.body.pgpKey, discordId: req.body.discordId, telegramChatId: req.body.telegramChatId, telegramSendSilently: req.body.telegramSendSilently, + notificationTypes: req.body.notificationTypes, }); } else { - user.settings.notificationAgents = - req.body.notificationAgents ?? NotificationAgentType.EMAIL; user.settings.pgpKey = req.body.pgpKey; user.settings.discordId = req.body.discordId; user.settings.telegramChatId = req.body.telegramChatId; user.settings.telegramSendSilently = req.body.telegramSendSilently; + user.settings.notificationTypes = Object.assign( + {}, + user.settings.notificationTypes, + req.body.notificationTypes + ); } userRepository.save(user); return res.status(200).json({ - notificationAgents: user.settings?.notificationAgents, pgpKey: user.settings?.pgpKey, discordId: user.settings?.discordId, telegramChatId: user.settings?.telegramChatId, telegramSendSilently: user?.settings?.telegramSendSilently, + notificationTypes: user.settings.notificationTypes, }); } catch (e) { next({ status: 500, message: e.message }); diff --git a/server/types/express.d.ts b/server/types/express.d.ts index 90a880069..ee7fd9724 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts @@ -6,6 +6,7 @@ declare global { namespace Express { export interface Request { user?: User; + locale?: string; } } diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index ea4f52b36..16d094294 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -3,14 +3,13 @@ import axios from 'axios'; import { uniq } from 'lodash'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import { MediaStatus } from '../../../server/constants/media'; import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { Collection } from '../../../server/models/Collection'; -import { LanguageContext } from '../../context/LanguageContext'; import useSettings from '../../hooks/useSettings'; import { Permission, useUser } from '../../hooks/useUser'; import globalMessages from '../../i18n/globalMessages'; @@ -48,14 +47,13 @@ const CollectionDetails: React.FC = ({ const router = useRouter(); const settings = useSettings(); const { addToast } = useToasts(); - const { locale } = useContext(LanguageContext); const { hasPermission } = useUser(); const [requestModal, setRequestModal] = useState(false); const [isRequesting, setRequesting] = useState(false); const [is4k, setIs4k] = useState(false); const { data, error, revalidate } = useSWR( - `/api/v1/collection/${router.query.collectionId}?language=${locale}`, + `/api/v1/collection/${router.query.collectionId}`, { initialData: collection, revalidateOnMount: true, @@ -63,7 +61,7 @@ const CollectionDetails: React.FC = ({ ); const { data: genres } = useSWR<{ id: number; name: string }[]>( - `/api/v1/genres/movie?language=${locale}` + `/api/v1/genres/movie` ); if (!data && !error) { diff --git a/src/components/Discover/MovieGenreList/index.tsx b/src/components/Discover/MovieGenreList/index.tsx index e7b124160..bc85adad4 100644 --- a/src/components/Discover/MovieGenreList/index.tsx +++ b/src/components/Discover/MovieGenreList/index.tsx @@ -1,24 +1,22 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import GenreCard from '../../GenreCard'; import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; -import { LanguageContext } from '../../../context/LanguageContext'; -import { genreColorMap } from '../constants'; -import PageTitle from '../../Common/PageTitle'; +import Error from '../../../pages/_error'; import Header from '../../Common/Header'; 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({ moviegenres: 'Movie Genres', }); const MovieGenreList: React.FC = () => { - const { locale } = useContext(LanguageContext); const intl = useIntl(); const { data, error } = useSWR( - `/api/v1/discover/genreslider/movie?language=${locale}` + `/api/v1/discover/genreslider/movie` ); if (!data && !error) { diff --git a/src/components/Discover/MovieGenreSlider/index.tsx b/src/components/Discover/MovieGenreSlider/index.tsx index 56abf7d9a..0933859c9 100644 --- a/src/components/Discover/MovieGenreSlider/index.tsx +++ b/src/components/Discover/MovieGenreSlider/index.tsx @@ -1,10 +1,9 @@ import { ArrowCircleRightIcon } from '@heroicons/react/outline'; import Link from 'next/link'; -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; -import { LanguageContext } from '../../../context/LanguageContext'; import GenreCard from '../../GenreCard'; import Slider from '../../Slider'; import { genreColorMap } from '../constants'; @@ -14,10 +13,9 @@ const messages = defineMessages({ }); const MovieGenreSlider: React.FC = () => { - const { locale } = useContext(LanguageContext); const intl = useIntl(); const { data, error } = useSWR( - `/api/v1/discover/genreslider/movie?language=${locale}`, + `/api/v1/discover/genreslider/movie`, { refreshInterval: 0, revalidateOnFocus: false, diff --git a/src/components/Discover/TvGenreList/index.tsx b/src/components/Discover/TvGenreList/index.tsx index 60eabc864..15fe9a017 100644 --- a/src/components/Discover/TvGenreList/index.tsx +++ b/src/components/Discover/TvGenreList/index.tsx @@ -1,24 +1,22 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import GenreCard from '../../GenreCard'; import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; -import { LanguageContext } from '../../../context/LanguageContext'; -import { genreColorMap } from '../constants'; -import PageTitle from '../../Common/PageTitle'; +import Error from '../../../pages/_error'; import Header from '../../Common/Header'; 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({ seriesgenres: 'Series Genres', }); const TvGenreList: React.FC = () => { - const { locale } = useContext(LanguageContext); const intl = useIntl(); const { data, error } = useSWR( - `/api/v1/discover/genreslider/tv?language=${locale}` + `/api/v1/discover/genreslider/tv` ); if (!data && !error) { diff --git a/src/components/Discover/TvGenreSlider/index.tsx b/src/components/Discover/TvGenreSlider/index.tsx index 37f1ee18b..6e6d7a954 100644 --- a/src/components/Discover/TvGenreSlider/index.tsx +++ b/src/components/Discover/TvGenreSlider/index.tsx @@ -1,10 +1,9 @@ import { ArrowCircleRightIcon } from '@heroicons/react/outline'; import Link from 'next/link'; -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; -import { LanguageContext } from '../../../context/LanguageContext'; import GenreCard from '../../GenreCard'; import Slider from '../../Slider'; import { genreColorMap } from '../constants'; @@ -14,10 +13,9 @@ const messages = defineMessages({ }); const TvGenreSlider: React.FC = () => { - const { locale } = useContext(LanguageContext); const intl = useIntl(); const { data, error } = useSWR( - `/api/v1/discover/genreslider/tv?language=${locale}`, + `/api/v1/discover/genreslider/tv`, { refreshInterval: 0, revalidateOnFocus: false, diff --git a/src/components/ExternalLinkBlock/index.tsx b/src/components/ExternalLinkBlock/index.tsx index be37d3d03..2c3357b06 100644 --- a/src/components/ExternalLinkBlock/index.tsx +++ b/src/components/ExternalLinkBlock/index.tsx @@ -1,11 +1,11 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { MediaType } from '../../../server/constants/media'; import ImdbLogo from '../../assets/services/imdb.svg'; import PlexLogo from '../../assets/services/plex.svg'; import RTLogo from '../../assets/services/rt.svg'; import TmdbLogo from '../../assets/services/tmdb.svg'; import TvdbLogo from '../../assets/services/tvdb.svg'; -import { LanguageContext } from '../../context/LanguageContext'; +import useLocale from '../../hooks/useLocale'; interface ExternalLinkBlockProps { mediaType: 'movie' | 'tv'; @@ -24,7 +24,7 @@ const ExternalLinkBlock: React.FC = ({ rtUrl, plexUrl, }) => { - const { locale } = useContext(LanguageContext); + const { locale } = useLocale(); return (
diff --git a/src/components/Layout/LanguagePicker/index.tsx b/src/components/Layout/LanguagePicker/index.tsx index 683fe5f43..cd589dde6 100644 --- a/src/components/Layout/LanguagePicker/index.tsx +++ b/src/components/Layout/LanguagePicker/index.tsx @@ -1,93 +1,22 @@ 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 { + availableLanguages, AvailableLocales, - LanguageContext, } from '../../../context/LanguageContext'; import useClickOutside from '../../../hooks/useClickOutside'; +import useLocale from '../../../hooks/useLocale'; import Transition from '../../Transition'; const messages = defineMessages({ 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 intl = useIntl(); const dropdownRef = useRef(null); - const { locale, setLocale } = useContext(LanguageContext); + const { locale, setLocale } = useLocale(); const [isDropdownOpen, setDropdownOpen] = useState(false); useClickOutside(dropdownRef, () => setDropdownOpen(false)); diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 7ea9ac64d..662868354 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -1,10 +1,9 @@ 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 React, { useEffect, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { Permission, useUser } from '../../hooks/useUser'; -import LanguagePicker from './LanguagePicker'; import SearchInput from './SearchInput'; import Sidebar from './Sidebar'; import UserDropdown from './UserDropdown'; @@ -23,7 +22,7 @@ const Layout: React.FC = ({ children }) => { useEffect(() => { const updateScrolled = () => { - if (window.pageYOffset > 60) { + if (window.pageYOffset > 20) { setIsScrolled(true); } else { setIsScrolled(false); @@ -55,16 +54,25 @@ const Layout: React.FC = ({ children }) => { }} > -
+
+
-
diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 64aa79153..dcb7eea4f 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -1,6 +1,6 @@ import { ArrowCircleRightIcon } from '@heroicons/react/outline'; import Link from 'next/link'; -import React, { useContext, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useSWRInfinite } from 'swr'; import { MediaStatus } from '../../../server/constants/media'; import type { @@ -8,7 +8,6 @@ import type { PersonResult, TvResult, } from '../../../server/models/Search'; -import { LanguageContext } from '../../context/LanguageContext'; import useSettings from '../../hooks/useSettings'; import PersonCard from '../PersonCard'; import Slider from '../Slider'; @@ -38,14 +37,13 @@ const MediaSlider: React.FC = ({ hideWhenEmpty = false, }) => { const settings = useSettings(); - const { locale } = useContext(LanguageContext); const { data, error, setSize, size } = useSWRInfinite( (pageIndex: number, previousPageData: MixedResult | null) => { if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { return null; } - return `${url}?page=${pageIndex + 1}&language=${locale}`; + return `${url}?page=${pageIndex + 1}`; }, { initialSize: 2, diff --git a/src/components/MovieDetails/MovieCast/index.tsx b/src/components/MovieDetails/MovieCast/index.tsx index 081a7a6d7..0cc9c2e03 100644 --- a/src/components/MovieDetails/MovieCast/index.tsx +++ b/src/components/MovieDetails/MovieCast/index.tsx @@ -1,15 +1,14 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import { MovieDetails } from '../../../../server/models/Movie'; -import { LanguageContext } from '../../../context/LanguageContext'; import Error from '../../../pages/_error'; import Header from '../../Common/Header'; import LoadingSpinner from '../../Common/LoadingSpinner'; -import PersonCard from '../../PersonCard'; import PageTitle from '../../Common/PageTitle'; +import PersonCard from '../../PersonCard'; const messages = defineMessages({ fullcast: 'Full Cast', @@ -18,9 +17,8 @@ const messages = defineMessages({ const MovieCast: React.FC = () => { const router = useRouter(); const intl = useIntl(); - const { locale } = useContext(LanguageContext); const { data, error } = useSWR( - `/api/v1/movie/${router.query.movieId}?language=${locale}` + `/api/v1/movie/${router.query.movieId}` ); if (!data && !error) { diff --git a/src/components/MovieDetails/MovieCrew/index.tsx b/src/components/MovieDetails/MovieCrew/index.tsx index f19cbc205..14268e425 100644 --- a/src/components/MovieDetails/MovieCrew/index.tsx +++ b/src/components/MovieDetails/MovieCrew/index.tsx @@ -1,15 +1,14 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import { MovieDetails } from '../../../../server/models/Movie'; -import { LanguageContext } from '../../../context/LanguageContext'; import Error from '../../../pages/_error'; import Header from '../../Common/Header'; import LoadingSpinner from '../../Common/LoadingSpinner'; -import PersonCard from '../../PersonCard'; import PageTitle from '../../Common/PageTitle'; +import PersonCard from '../../PersonCard'; const messages = defineMessages({ fullcrew: 'Full Crew', @@ -18,9 +17,8 @@ const messages = defineMessages({ const MovieCrew: React.FC = () => { const router = useRouter(); const intl = useIntl(); - const { locale } = useContext(LanguageContext); const { data, error } = useSWR( - `/api/v1/movie/${router.query.movieId}?language=${locale}` + `/api/v1/movie/${router.query.movieId}` ); if (!data && !error) { diff --git a/src/components/MovieDetails/MovieRecommendations.tsx b/src/components/MovieDetails/MovieRecommendations.tsx index b603e7b5b..fc9c2bf2c 100644 --- a/src/components/MovieDetails/MovieRecommendations.tsx +++ b/src/components/MovieDetails/MovieRecommendations.tsx @@ -1,16 +1,15 @@ -import React, { useContext } from 'react'; -import useSWR from 'swr'; -import type { MovieResult } from '../../../server/models/Search'; -import ListView from '../Common/ListView'; +import Link from 'next/link'; import { useRouter } from 'next/router'; -import Header from '../Common/Header'; -import type { MovieDetails } from '../../../server/models/Movie'; -import { LanguageContext } from '../../context/LanguageContext'; +import React from 'react'; 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 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({ recommendations: 'Recommendations', @@ -19,9 +18,8 @@ const messages = defineMessages({ const MovieRecommendations: React.FC = () => { const intl = useIntl(); const router = useRouter(); - const { locale } = useContext(LanguageContext); const { data: movieData } = useSWR( - `/api/v1/movie/${router.query.movieId}?language=${locale}` + `/api/v1/movie/${router.query.movieId}` ); const { isLoadingInitialData, diff --git a/src/components/MovieDetails/MovieSimilar.tsx b/src/components/MovieDetails/MovieSimilar.tsx index 93bacc366..8103f966e 100644 --- a/src/components/MovieDetails/MovieSimilar.tsx +++ b/src/components/MovieDetails/MovieSimilar.tsx @@ -1,16 +1,15 @@ -import React, { useContext } from 'react'; -import useSWR from 'swr'; -import type { MovieResult } from '../../../server/models/Search'; -import ListView from '../Common/ListView'; +import Link from 'next/link'; import { useRouter } from 'next/router'; -import Header from '../Common/Header'; -import { LanguageContext } from '../../context/LanguageContext'; -import type { MovieDetails } from '../../../server/models/Movie'; +import React from 'react'; 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 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({ similar: 'Similar Titles', @@ -19,9 +18,8 @@ const messages = defineMessages({ const MovieSimilar: React.FC = () => { const router = useRouter(); const intl = useIntl(); - const { locale } = useContext(LanguageContext); const { data: movieData } = useSWR( - `/api/v1/movie/${router.query.movieId}?language=${locale}` + `/api/v1/movie/${router.query.movieId}` ); const { isLoadingInitialData, diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 7db6b9465..8675898cb 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -12,7 +12,7 @@ import { import axios from 'axios'; import Link from 'next/link'; 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 useSWR from 'swr'; 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 RTRotten from '../../assets/rt_rotten.svg'; import TmdbLogo from '../../assets/tmdb_logo.svg'; -import { LanguageContext } from '../../context/LanguageContext'; +import useLocale from '../../hooks/useLocale'; import useSettings from '../../hooks/useSettings'; import { Permission, useUser } from '../../hooks/useUser'; import globalMessages from '../../i18n/globalMessages'; @@ -84,11 +84,11 @@ const MovieDetails: React.FC = ({ movie }) => { const { user, hasPermission } = useUser(); const router = useRouter(); const intl = useIntl(); - const { locale } = useContext(LanguageContext); + const { locale } = useLocale(); const [showManager, setShowManager] = useState(false); const { data, error, revalidate } = useSWR( - `/api/v1/movie/${router.query.movieId}?language=${locale}`, + `/api/v1/movie/${router.query.movieId}`, { initialData: movie, } diff --git a/src/components/NotificationTypeSelector/index.tsx b/src/components/NotificationTypeSelector/index.tsx index b549613f7..273500070 100644 --- a/src/components/NotificationTypeSelector/index.tsx +++ b/src/components/NotificationTypeSelector/index.tsx @@ -53,6 +53,10 @@ export enum Notification { 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 { id: string; name: string; diff --git a/src/components/PWAHeader/index.tsx b/src/components/PWAHeader/index.tsx new file mode 100644 index 000000000..d8a9eba13 --- /dev/null +++ b/src/components/PWAHeader/index.tsx @@ -0,0 +1,183 @@ +import React from 'react'; + +interface PWAHeaderProps { + applicationTitle?: string; +} + +const PWAHeader: React.FC = ({ applicationTitle }) => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default PWAHeader; diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index a0082c79c..3ea148c06 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -1,13 +1,12 @@ import { groupBy } from 'lodash'; 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 TruncateMarkup from 'react-truncate-markup'; import useSWR from 'swr'; import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces'; import type { PersonDetail } from '../../../server/models/Person'; import Ellipsis from '../../assets/ellipsis.svg'; -import { LanguageContext } from '../../context/LanguageContext'; import globalMessages from '../../i18n/globalMessages'; import Error from '../../pages/_error'; import CachedImage from '../Common/CachedImage'; @@ -27,10 +26,9 @@ const messages = defineMessages({ const PersonDetails: React.FC = () => { const intl = useIntl(); - const { locale } = useContext(LanguageContext); const router = useRouter(); const { data, error } = useSWR( - `/api/v1/person/${router.query.personId}?language=${locale}` + `/api/v1/person/${router.query.personId}` ); const [showBio, setShowBio] = useState(false); @@ -38,7 +36,7 @@ const PersonDetails: React.FC = () => { data: combinedCredits, error: errorCombinedCredits, } = useSWR( - `/api/v1/person/${router.query.personId}/combined_credits?language=${locale}` + `/api/v1/person/${router.query.personId}/combined_credits` ); const sortedCast = useMemo(() => { diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 7e71813e3..867795e12 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -1,7 +1,7 @@ import { CheckIcon, TrashIcon, XIcon } from '@heroicons/react/solid'; import axios from 'axios'; import Link from 'next/link'; -import React, { useContext, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useInView } from 'react-intersection-observer'; import { defineMessages, useIntl } from 'react-intl'; import useSWR, { mutate } from 'swr'; @@ -12,7 +12,6 @@ import { import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { MovieDetails } from '../../../server/models/Movie'; import type { TvDetails } from '../../../server/models/Tv'; -import { LanguageContext } from '../../context/LanguageContext'; import { Permission, useUser } from '../../hooks/useUser'; import globalMessages from '../../i18n/globalMessages'; import { withProperties } from '../../utils/typeHelpers'; @@ -92,13 +91,12 @@ const RequestCard: React.FC = ({ request, onTitleData }) => { }); const intl = useIntl(); const { hasPermission } = useUser(); - const { locale } = useContext(LanguageContext); const url = request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` : `/api/v1/tv/${request.media.tmdbId}`; const { data: title, error } = useSWR( - inView ? `${url}?language=${locale}` : null + inView ? `${url}` : null ); const { data: requestData, diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 01fb1ddc1..84de66cb3 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -7,7 +7,7 @@ import { } from '@heroicons/react/solid'; import axios from 'axios'; import Link from 'next/link'; -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; import { useInView } from 'react-intersection-observer'; import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; @@ -19,7 +19,6 @@ import { import type { MediaRequest } from '../../../../server/entity/MediaRequest'; import type { MovieDetails } from '../../../../server/models/Movie'; import type { TvDetails } from '../../../../server/models/Tv'; -import { LanguageContext } from '../../../context/LanguageContext'; import { Permission, useUser } from '../../../hooks/useUser'; import globalMessages from '../../../i18n/globalMessages'; import Badge from '../../Common/Badge'; @@ -99,13 +98,12 @@ const RequestItem: React.FC = ({ const intl = useIntl(); const { user, hasPermission } = useUser(); const [showEditModal, setShowEditModal] = useState(false); - const { locale } = useContext(LanguageContext); const url = request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` : `/api/v1/tv/${request.media.tmdbId}`; const { data: title, error } = useSWR( - inView ? `${url}?language=${locale}` : null + inView ? `${url}` : null ); const { data: requestData, revalidate, mutate } = useSWR( `/api/v1/request/${request.id}`, diff --git a/src/components/ServiceWorkerSetup/index.tsx b/src/components/ServiceWorkerSetup/index.tsx new file mode 100644 index 000000000..56a558a3d --- /dev/null +++ b/src/components/ServiceWorkerSetup/index.tsx @@ -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; diff --git a/src/components/Settings/Notifications/NotificationsWebPush/index.tsx b/src/components/Settings/Notifications/NotificationsWebPush/index.tsx new file mode 100644 index 000000000..c1db453e0 --- /dev/null +++ b/src/components/Settings/Notifications/NotificationsWebPush/index.tsx @@ -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 ; + } + + return ( + <> + { + 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 ( +
+
+ +
+ +
+
+ setFieldValue('types', newTypes)} + /> +
+
+ + + + + + +
+
+ + ); + }} +
+ + ); +}; + +export default NotificationsWebPush; diff --git a/src/components/Settings/SettingsNotifications.tsx b/src/components/Settings/SettingsNotifications.tsx index 3c73c001b..88cfb274f 100644 --- a/src/components/Settings/SettingsNotifications.tsx +++ b/src/components/Settings/SettingsNotifications.tsx @@ -1,5 +1,5 @@ import { AtSymbolIcon } from '@heroicons/react/outline'; -import { LightningBoltIcon } from '@heroicons/react/solid'; +import { CloudIcon, LightningBoltIcon } from '@heroicons/react/solid'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import DiscordLogo from '../../assets/extlogos/discord.svg'; @@ -18,6 +18,7 @@ const messages = defineMessages({ 'Configure and enable notification agents.', email: 'Email', webhook: 'Webhook', + webpush: 'Web Push', }); const SettingsNotifications: React.FC = ({ children }) => { @@ -90,6 +91,17 @@ const SettingsNotifications: React.FC = ({ children }) => { route: '/settings/notifications/telegram', regex: /^\/settings\/notifications\/telegram/, }, + { + text: intl.formatMessage(messages.webpush), + content: ( + + + {intl.formatMessage(messages.webpush)} + + ), + route: '/settings/notifications/webpush', + regex: /^\/settings\/notifications\/webpush/, + }, { text: intl.formatMessage(messages.webhook), content: ( diff --git a/src/components/TitleCard/TmdbTitleCard.tsx b/src/components/TitleCard/TmdbTitleCard.tsx index 40325a30b..a783037a1 100644 --- a/src/components/TitleCard/TmdbTitleCard.tsx +++ b/src/components/TitleCard/TmdbTitleCard.tsx @@ -1,10 +1,9 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { useInView } from 'react-intersection-observer'; import useSWR from 'swr'; +import TitleCard from '.'; import type { MovieDetails } from '../../../server/models/Movie'; import type { TvDetails } from '../../../server/models/Tv'; -import TitleCard from '.'; -import { LanguageContext } from '../../context/LanguageContext'; interface TmdbTitleCardProps { tmdbId: number; @@ -19,11 +18,10 @@ const TmdbTitleCard: React.FC = ({ tmdbId, type }) => { const { ref, inView } = useInView({ triggerOnce: true, }); - const { locale } = useContext(LanguageContext); const url = type === 'movie' ? `/api/v1/movie/${tmdbId}` : `/api/v1/tv/${tmdbId}`; const { data: title, error } = useSWR( - inView ? `${url}?language=${locale}` : null + inView ? `${url}` : null ); if (!title && !error) { diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx index aaad91a33..92ae5a76f 100644 --- a/src/components/Toast/index.tsx +++ b/src/components/Toast/index.tsx @@ -7,41 +7,57 @@ import { import { XIcon } from '@heroicons/react/solid'; import React from 'react'; import type { ToastProps } from 'react-toast-notifications'; +import Transition from '../Transition'; -const Toast: React.FC = ({ appearance, children, onDismiss }) => { +const Toast: React.FC = ({ + appearance, + children, + onDismiss, + transitionState, +}) => { return (
-
-
-
-
-
- {appearance === 'success' && ( - - )} - {appearance === 'error' && ( - - )} - {appearance === 'info' && ( - - )} - {appearance === 'warning' && ( - - )} -
-
{children}
-
- + +
+
+
+
+
+ {appearance === 'success' && ( + + )} + {appearance === 'error' && ( + + )} + {appearance === 'info' && ( + + )} + {appearance === 'warning' && ( + + )} +
+
{children}
+
+ +
-
+
); }; diff --git a/src/components/ToastContainer/index.tsx b/src/components/ToastContainer/index.tsx new file mode 100644 index 000000000..ea481737f --- /dev/null +++ b/src/components/ToastContainer/index.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { ToastContainerProps } from 'react-toast-notifications'; + +const ToastContainer: React.FC = ({ + hasToasts, + ...props +}) => { + return ( +
+ ); +}; + +export default ToastContainer; diff --git a/src/components/TvDetails/TvCast/index.tsx b/src/components/TvDetails/TvCast/index.tsx index 78cfccc19..9631ad491 100644 --- a/src/components/TvDetails/TvCast/index.tsx +++ b/src/components/TvDetails/TvCast/index.tsx @@ -1,15 +1,14 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import type { TvDetails } from '../../../../server/models/Tv'; -import { LanguageContext } from '../../../context/LanguageContext'; import Error from '../../../pages/_error'; import Header from '../../Common/Header'; import LoadingSpinner from '../../Common/LoadingSpinner'; -import PersonCard from '../../PersonCard'; import PageTitle from '../../Common/PageTitle'; +import PersonCard from '../../PersonCard'; const messages = defineMessages({ fullseriescast: 'Full Series Cast', @@ -18,10 +17,7 @@ const messages = defineMessages({ const TvCast: React.FC = () => { const router = useRouter(); const intl = useIntl(); - const { locale } = useContext(LanguageContext); - const { data, error } = useSWR( - `/api/v1/tv/${router.query.tvId}?language=${locale}` - ); + const { data, error } = useSWR(`/api/v1/tv/${router.query.tvId}`); if (!data && !error) { return ; diff --git a/src/components/TvDetails/TvCrew/index.tsx b/src/components/TvDetails/TvCrew/index.tsx index 64c1af834..5ed0297d2 100644 --- a/src/components/TvDetails/TvCrew/index.tsx +++ b/src/components/TvDetails/TvCrew/index.tsx @@ -1,15 +1,14 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useContext } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import type { TvDetails } from '../../../../server/models/Tv'; -import { LanguageContext } from '../../../context/LanguageContext'; import Error from '../../../pages/_error'; import Header from '../../Common/Header'; import LoadingSpinner from '../../Common/LoadingSpinner'; -import PersonCard from '../../PersonCard'; import PageTitle from '../../Common/PageTitle'; +import PersonCard from '../../PersonCard'; const messages = defineMessages({ fullseriescrew: 'Full Series Crew', @@ -18,10 +17,7 @@ const messages = defineMessages({ const TvCrew: React.FC = () => { const router = useRouter(); const intl = useIntl(); - const { locale } = useContext(LanguageContext); - const { data, error } = useSWR( - `/api/v1/tv/${router.query.tvId}?language=${locale}` - ); + const { data, error } = useSWR(`/api/v1/tv/${router.query.tvId}`); if (!data && !error) { return ; diff --git a/src/components/TvDetails/TvRecommendations.tsx b/src/components/TvDetails/TvRecommendations.tsx index c5aa7b04a..94e6f761b 100644 --- a/src/components/TvDetails/TvRecommendations.tsx +++ b/src/components/TvDetails/TvRecommendations.tsx @@ -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 type { TvResult } from '../../../server/models/Search'; -import ListView from '../Common/ListView'; -import { useRouter } from 'next/router'; -import { LanguageContext } from '../../context/LanguageContext'; -import Header from '../Common/Header'; -import { defineMessages, useIntl } from 'react-intl'; import { TvDetails } from '../../../server/models/Tv'; -import PageTitle from '../Common/PageTitle'; -import Error from '../../pages/_error'; 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({ recommendations: 'Recommendations', @@ -19,10 +18,7 @@ const messages = defineMessages({ const TvRecommendations: React.FC = () => { const router = useRouter(); const intl = useIntl(); - const { locale } = useContext(LanguageContext); - const { data: tvData } = useSWR( - `/api/v1/tv/${router.query.tvId}?language=${locale}` - ); + const { data: tvData } = useSWR(`/api/v1/tv/${router.query.tvId}`); const { isLoadingInitialData, isEmpty, diff --git a/src/components/TvDetails/TvSimilar.tsx b/src/components/TvDetails/TvSimilar.tsx index c09cca28a..a82147470 100644 --- a/src/components/TvDetails/TvSimilar.tsx +++ b/src/components/TvDetails/TvSimilar.tsx @@ -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 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 Header from '../Common/Header'; -import PageTitle from '../Common/PageTitle'; import useDiscover from '../../hooks/useDiscover'; 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({ similar: 'Similar Series', @@ -19,10 +18,7 @@ const messages = defineMessages({ const TvSimilar: React.FC = () => { const router = useRouter(); const intl = useIntl(); - const { locale } = useContext(LanguageContext); - const { data: tvData } = useSWR( - `/api/v1/tv/${router.query.tvId}?language=${locale}` - ); + const { data: tvData } = useSWR(`/api/v1/tv/${router.query.tvId}`); const { isLoadingInitialData, isEmpty, diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 69c0e9c39..8406f13ea 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -12,7 +12,7 @@ import { import axios from 'axios'; import Link from 'next/link'; 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 useSWR from 'swr'; 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 RTRotten from '../../assets/rt_rotten.svg'; import TmdbLogo from '../../assets/tmdb_logo.svg'; -import { LanguageContext } from '../../context/LanguageContext'; +import useLocale from '../../hooks/useLocale'; import useSettings from '../../hooks/useSettings'; import { Permission, useUser } from '../../hooks/useUser'; import globalMessages from '../../i18n/globalMessages'; @@ -91,12 +91,12 @@ const TvDetails: React.FC = ({ tv }) => { const { user, hasPermission } = useUser(); const router = useRouter(); const intl = useIntl(); - const { locale } = useContext(LanguageContext); + const { locale } = useLocale(); const [showRequestModal, setShowRequestModal] = useState(false); const [showManager, setShowManager] = useState(false); const { data, error, revalidate } = useSWR( - `/api/v1/tv/${router.query.tvId}?language=${locale}`, + `/api/v1/tv/${router.query.tvId}`, { initialData: tv, } diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index d9c455e20..280592977 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -7,6 +7,8 @@ import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import { UserSettingsGeneralResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces'; import { Language } from '../../../../../server/lib/settings'; +import { availableLanguages } from '../../../../context/LanguageContext'; +import useLocale from '../../../../hooks/useLocale'; import useSettings from '../../../../hooks/useSettings'; import { Permission, UserType, useUser } from '../../../../hooks/useUser'; import globalMessages from '../../../../i18n/globalMessages'; @@ -39,11 +41,13 @@ const messages = defineMessages({ movierequestlimit: 'Movie Request Limit', seriesrequestlimit: 'Series Request Limit', enableOverride: 'Enable Override', + applanguage: 'Display Language', }); const UserGeneralSettings: React.FC = () => { const intl = useIntl(); const { addToast } = useToasts(); + const { locale, setLocale } = useLocale(); const [movieQuotaEnabled, setMovieQuotaEnabled] = useState(false); const [tvQuotaEnabled, setTvQuotaEnabled] = useState(false); const router = useRouter(); @@ -115,6 +119,7 @@ const UserGeneralSettings: React.FC = () => {
{ movieQuotaDays: movieQuotaEnabled ? values.movieQuotaDays : null, tvQuotaLimit: tvQuotaEnabled ? values.tvQuotaLimit : null, tvQuotaDays: tvQuotaEnabled ? values.tvQuotaDays : null, + locale: values.locale, }); + if (setLocale) { + setLocale(values.locale); + } + addToast(intl.formatMessage(messages.toastSettingsSuccess), { autoDismiss: true, appearance: 'success', @@ -206,6 +216,24 @@ const UserGeneralSettings: React.FC = () => { )}
+
+ +
+
+ + {(Object.keys( + availableLanguages + ) as (keyof typeof availableLanguages)[]).map((key) => ( + + ))} + +
+
+
diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx index b949fb95a..b8123c909 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx @@ -1,21 +1,18 @@ import axios from 'axios'; import { Field, Form, Formik } from 'formik'; import { useRouter } from 'next/router'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import * as Yup from 'yup'; import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces'; -import { - hasNotificationAgentEnabled, - NotificationAgentType, -} from '../../../../../server/lib/notifications/agenttypes'; import { useUser } from '../../../../hooks/useUser'; import globalMessages from '../../../../i18n/globalMessages'; import Badge from '../../../Common/Badge'; import Button from '../../../Common/Button'; import LoadingSpinner from '../../../Common/LoadingSpinner'; +import { ALL_NOTIFICATIONS } from '../../../NotificationTypeSelector'; import { OpenPgpLink } from '../../../Settings/Notifications/NotificationsEmail'; const messages = defineMessages({ @@ -32,18 +29,11 @@ const UserEmailSettings: React.FC = () => { const intl = useIntl(); const { addToast } = useToasts(); const router = useRouter(); - const [notificationAgents, setNotificationAgents] = useState(0); const { user } = useUser({ id: Number(router.query.userId) }); const { data, error, revalidate } = useSWR( user ? `/api/v1/user/${user?.id}/settings/notifications` : null ); - useEffect(() => { - setNotificationAgents( - data?.notificationAgents ?? NotificationAgentType.EMAIL - ); - }, [data]); - const UserNotificationsEmailSchema = Yup.object().shape({ pgpKey: Yup.string() .nullable() @@ -60,10 +50,7 @@ const UserEmailSettings: React.FC = () => { return ( { onSubmit={async (values) => { try { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { - notificationAgents, pgpKey: values.pgpKey, discordId: data?.discordId, telegramChatId: data?.telegramChatId, telegramSendSilently: data?.telegramSendSilently, + notificationTypes: { + email: values.enableEmail ? ALL_NOTIFICATIONS : 0, + }, }); addToast(intl.formatMessage(messages.emailsettingssaved), { appearance: 'success', @@ -91,7 +80,7 @@ const UserEmailSettings: React.FC = () => { } }} > - {({ errors, touched, isSubmitting, isValid, values, setFieldValue }) => { + {({ errors, touched, isSubmitting, isValid }) => { return (
@@ -99,26 +88,7 @@ const UserEmailSettings: React.FC = () => { {intl.formatMessage(messages.enableEmail)}
- { - setNotificationAgents( - hasNotificationAgentEnabled( - NotificationAgentType.EMAIL, - notificationAgents - ) - ? notificationAgents - NotificationAgentType.EMAIL - : notificationAgents + NotificationAgentType.EMAIL - ); - setFieldValue('enableEmail', !values.enableEmail); - }} - /> +
diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx index 6193e127f..26ad42533 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx @@ -1,20 +1,17 @@ import axios from 'axios'; import { Field, Form, Formik } from 'formik'; import { useRouter } from 'next/router'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import * as Yup from 'yup'; import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces'; -import { - hasNotificationAgentEnabled, - NotificationAgentType, -} from '../../../../../server/lib/notifications/agenttypes'; 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({ telegramsettingssaved: 'Telegram notification settings saved successfully!', @@ -32,18 +29,11 @@ const UserTelegramSettings: React.FC = () => { const intl = useIntl(); const { addToast } = useToasts(); const router = useRouter(); - const [notificationAgents, setNotificationAgents] = useState(0); const { user } = useUser({ id: Number(router.query.userId) }); const { data, error, revalidate } = useSWR( user ? `/api/v1/user/${user?.id}/settings/notifications` : null ); - useEffect(() => { - setNotificationAgents( - data?.notificationAgents ?? NotificationAgentType.EMAIL - ); - }, [data]); - const UserNotificationsTelegramSchema = Yup.object().shape({ telegramChatId: Yup.string() .when('enableTelegram', { @@ -66,10 +56,7 @@ const UserTelegramSettings: React.FC = () => { return ( { onSubmit={async (values) => { try { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { - notificationAgents, pgpKey: data?.pgpKey, discordId: data?.discordId, telegramChatId: values.telegramChatId, telegramSendSilently: values.telegramSendSilently, + notificationTypes: { + telegram: values.enableTelegram ? ALL_NOTIFICATIONS : 0, + }, }); addToast(intl.formatMessage(messages.telegramsettingssaved), { appearance: 'success', @@ -98,7 +87,7 @@ const UserTelegramSettings: React.FC = () => { } }} > - {({ errors, touched, isSubmitting, isValid, values, setFieldValue }) => { + {({ errors, touched, isSubmitting, isValid }) => { return (
@@ -110,21 +99,6 @@ const UserTelegramSettings: React.FC = () => { type="checkbox" id="enableTelegram" name="enableTelegram" - checked={hasNotificationAgentEnabled( - NotificationAgentType.TELEGRAM, - notificationAgents - )} - onChange={() => { - setNotificationAgents( - hasNotificationAgentEnabled( - NotificationAgentType.TELEGRAM, - notificationAgents - ) - ? notificationAgents - NotificationAgentType.TELEGRAM - : notificationAgents + NotificationAgentType.TELEGRAM - ); - setFieldValue('enableTelegram', !values.enableTelegram); - }} />
diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx new file mode 100644 index 000000000..76cf94ece --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx @@ -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( + user ? `/api/v1/user/${user?.id}/settings/notifications` : null + ); + + if (!data && !error) { + return ; + } + + return ( + { + 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 ( + +
+ +
+ +
+
+
+
+ + + +
+
+ + ); + }} +
+ ); +}; + +export default UserWebPushSettings; diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx index e0d68421d..caec6fde1 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx @@ -1,4 +1,5 @@ import { AtSymbolIcon } from '@heroicons/react/outline'; +import { CloudIcon } from '@heroicons/react/solid'; import { useRouter } from 'next/router'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; @@ -17,6 +18,7 @@ const messages = defineMessages({ notifications: 'Notifications', notificationsettings: 'Notification Settings', email: 'Email', + webpush: 'Web Push', toastSettingsSuccess: 'Notification settings saved successfully!', toastSettingsFailure: 'Something went wrong while saving settings.', }); @@ -65,6 +67,18 @@ const UserNotificationSettings: React.FC = ({ children }) => { regex: /\/settings\/notifications\/telegram/, hidden: !data?.telegramEnabled || !data?.telegramBotUsername, }, + { + text: intl.formatMessage(messages.webpush), + content: ( + + + {intl.formatMessage(messages.webpush)} + + ), + route: '/settings/notifications/webpush', + regex: /\/settings\/notifications\/webpush/, + hidden: !data?.webPushEnabled, + }, ]; settingsRoutes.forEach((settingsRoute) => { diff --git a/src/context/LanguageContext.tsx b/src/context/LanguageContext.tsx index 6a0a12ab4..0ff62940b 100644 --- a/src/context/LanguageContext.tsx +++ b/src/context/LanguageContext.tsx @@ -18,7 +18,79 @@ export type AvailableLocales = | 'sv' | '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; children: (locale: string) => ReactNode; setLocale?: React.Dispatch>; diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index 8c9033f0f..749148fb5 100644 --- a/src/context/SettingsContext.tsx +++ b/src/context/SettingsContext.tsx @@ -17,6 +17,8 @@ const defaultSettings = { originalLanguage: '', partialRequestsEnabled: true, cacheImages: false, + vapidPublic: '', + enablePushRegistration: false, }; export const SettingsContext = React.createContext({ diff --git a/src/hooks/useDiscover.ts b/src/hooks/useDiscover.ts index ecba66893..e71acc4ea 100644 --- a/src/hooks/useDiscover.ts +++ b/src/hooks/useDiscover.ts @@ -1,7 +1,5 @@ -import { useContext } from 'react'; import { useSWRInfinite } from 'swr'; import { MediaStatus } from '../../server/constants/media'; -import { LanguageContext } from '../context/LanguageContext'; import useSettings from './useSettings'; export interface BaseSearchResult { @@ -35,7 +33,6 @@ const useDiscover = >( { hideAvailable = true } = {} ): DiscoverResult => { const settings = useSettings(); - const { locale } = useContext(LanguageContext); const { data, error, size, setSize, isValidating } = useSWRInfinite< BaseSearchResult & S >( @@ -46,7 +43,6 @@ const useDiscover = >( const params: Record = { page: pageIndex + 1, - language: locale, ...options, }; diff --git a/src/hooks/useLocale.ts b/src/hooks/useLocale.ts new file mode 100644 index 000000000..a0281e7ed --- /dev/null +++ b/src/hooks/useLocale.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import { + LanguageContext, + LanguageContextProps, +} from '../context/LanguageContext'; + +const useLocale = (): Omit => { + const languageContext = useContext(LanguageContext); + + return languageContext; +}; + +export default useLocale; diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index 867303f12..dd28a9f27 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -6,6 +6,7 @@ import { Permission, PermissionCheckOptions, } from '../../server/lib/permissions'; +import { NotificationAgentKey } from '../../server/lib/settings'; export { Permission, UserType }; export type { PermissionCheckOptions }; @@ -25,10 +26,14 @@ export interface User { settings?: UserSettings; } +type NotificationAgentTypes = Record; + export interface UserSettings { discordId?: string; region?: string; originalLanguage?: string; + locale?: string; + notificationTypes: Partial; } interface UserHookResponse { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index c095ced79..57def5a15 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -268,6 +268,10 @@ "components.Settings.Notifications.NotificationsSlack.testsent": "Slack test notification sent!", "components.Settings.Notifications.NotificationsSlack.validationWebhookUrl": "You must provide a valid 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.authheader": "Authorization Header", "components.Settings.Notifications.NotificationsWebhook.customJson": "JSON Payload", @@ -599,6 +603,7 @@ "components.Settings.validationHostnameRequired": "You must provide a hostname or IP address", "components.Settings.validationPortRequired": "You must provide a valid port number", "components.Settings.webhook": "Webhook", + "components.Settings.webpush": "Web Push", "components.Setup.configureplex": "Configure Plex", "components.Setup.configureservices": "Configure Services", "components.Setup.continue": "Continue", @@ -694,6 +699,7 @@ "components.UserProfile.ProfileHeader.userid": "User ID: {userid}", "components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type", "components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin", + "components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language", "components.UserProfile.UserSettings.UserGeneralSettings.displayName": "Display Name", "components.UserProfile.UserSettings.UserGeneralSettings.enableOverride": "Enable Override", "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.discordsettingssaved": "Discord notification settings saved successfully!", "components.UserProfile.UserSettings.UserNotificationSettings.email": "Email", - "components.UserProfile.UserSettings.UserNotificationSettings.emailsettingsfailed": "Email notification settings failed to save.", - "components.UserProfile.UserSettings.UserNotificationSettings.emailsettingssaved": "Email notification settings saved successfully!", + "components.UserProfile.UserSettings.UserNotificationSettings.emailsettingsfailed": "Web push notification settings failed to save.", + "components.UserProfile.UserSettings.UserNotificationSettings.emailsettingssaved": "Web push notification settings saved successfully!", "components.UserProfile.UserSettings.UserNotificationSettings.enableDiscord": "Enable Mentions", "components.UserProfile.UserSettings.UserNotificationSettings.enableEmail": "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.notificationsettings": "Notification Settings", "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.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.webpush": "Web Push", "components.UserProfile.UserSettings.UserPasswordChange.confirmpassword": "Confirm Password", "components.UserProfile.UserSettings.UserPasswordChange.currentpassword": "Current Password", "components.UserProfile.UserSettings.UserPasswordChange.newpassword": "New Password", diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index a67084d94..6b210cb6e 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,7 +1,6 @@ import axios from 'axios'; import App, { AppInitialProps, AppProps } from 'next/app'; import Head from 'next/head'; -import { parseCookies, setCookie } from 'nookies'; import React, { useEffect, useState } from 'react'; import { IntlProvider } from 'react-intl'; import { ToastProvider } from 'react-toast-notifications'; @@ -9,8 +8,11 @@ import { SWRConfig } from 'swr'; import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces'; import Layout from '../components/Layout'; import LoadingBar from '../components/LoadingBar'; +import PWAHeader from '../components/PWAHeader'; +import ServiceWorkerSetup from '../components/ServiceWorkerSetup'; import StatusChecker from '../components/StatusChacker'; import Toast from '../components/Toast'; +import ToastContainer from '../components/ToastContainer'; import { InteractionProvider } from '../context/InteractionContext'; import { AvailableLocales, LanguageContext } from '../context/LanguageContext'; import { SettingsProvider } from '../context/SettingsContext'; @@ -88,10 +90,6 @@ const CoreApp: Omit = ({ useEffect(() => { loadLocaleData(currentLocale).then(setMessages); - setCookie(null, 'locale', currentLocale, { - path: '/', - maxAge: 60 * 60 * 24 * 365 * 10, - }); }, [currentLocale]); if (router.pathname.match(/(login|setup|resetpassword)/)) { @@ -119,15 +117,19 @@ const CoreApp: Omit = ({ - + Overseerr + + {component} @@ -140,7 +142,7 @@ const CoreApp: Omit = ({ CoreApp.getInitialProps = async (initialProps) => { const { ctx, router } = initialProps; - let user = undefined; + let user: User | undefined = undefined; let currentSettings: PublicSettingsResponse = { initialized: false, applicationTitle: '', @@ -152,10 +154,10 @@ CoreApp.getInitialProps = async (initialProps) => { originalLanguage: '', partialRequestsEnabled: true, cacheImages: false, + vapidPublic: '', + enablePushRegistration: false, }; - let locale = 'en'; - if (ctx.res) { // Check if app is initialized and redirect if necessary const response = await axios.get( @@ -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 @@ -213,6 +209,8 @@ CoreApp.getInitialProps = async (initialProps) => { initialProps ); + const locale = user?.settings?.locale ?? 'en'; + const messages = await loadLocaleData(locale as AvailableLocales); return { ...appInitialProps, user, messages, locale, currentSettings }; diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx index efef45f6a..2dc43d659 100644 --- a/src/pages/_document.tsx +++ b/src/pages/_document.tsx @@ -22,163 +22,6 @@ class MyDocument extends Document { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/src/pages/profile/settings/notifications/webpush.tsx b/src/pages/profile/settings/notifications/webpush.tsx new file mode 100644 index 000000000..a44f254c0 --- /dev/null +++ b/src/pages/profile/settings/notifications/webpush.tsx @@ -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 ( + + + + + + ); +}; + +export default WebPushProfileNotificationsPage; diff --git a/src/pages/settings/notifications/webpush.tsx b/src/pages/settings/notifications/webpush.tsx new file mode 100644 index 000000000..d238b4e17 --- /dev/null +++ b/src/pages/settings/notifications/webpush.tsx @@ -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 ( + + + + + + ); +}; + +export default NotificationsWebPushPage; diff --git a/src/pages/users/[userId]/settings/notifications/webpush.tsx b/src/pages/users/[userId]/settings/notifications/webpush.tsx new file mode 100644 index 000000000..ddba1e3f5 --- /dev/null +++ b/src/pages/users/[userId]/settings/notifications/webpush.tsx @@ -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 ( + + + + + + ); +}; + +export default WebPushNotificationsPage; diff --git a/src/styles/globals.css b/src/styles/globals.css index 81e751348..39129f563 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -402,3 +402,9 @@ input[type='search']::-webkit-search-cancel-button { @apply text-white border-none; box-shadow: none; } + +@media all and (display-mode: browser) { + .pwa-only { + @apply hidden; + } +} diff --git a/yarn.lock b/yarn.lock index 333bc2430..1dd6452be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2443,6 +2443,13 @@ resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" 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": version "0.4.8" 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" 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" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== @@ -3461,6 +3468,11 @@ browserslist@^4.16.3: escalade "^3.1.1" 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: version "1.1.1" 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" 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: version "1.0.0" 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" 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: version "1.0.0" 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" 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: version "3.1.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -13763,6 +13806,11 @@ url@^0.11.0: punycode "1.3.2" 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: version "1.5.1" resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.5.1.tgz#73501107f02fad84c6dd57965beb0b75c68c42d1" @@ -13930,6 +13978,18 @@ wcwidth@^1.0.0: dependencies: 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: version "5.0.0" resolved "https://registry.yarnpkg.com/web-resource-inliner/-/web-resource-inliner-5.0.0.tgz#ac30db8096931f20a7c1b3ade54ff444e2e20f7b"