diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..a162593a --- /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 08bf1b5c..e8258adf 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 2c25865a..a8df8fef 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 692f01a8..08880f82 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 00000000..de21f914 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 34d1f9e1..23eb4a8e 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 00000000..82afcee5 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 00000000..01658360 --- /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 946ef07a..e393f166 100644 Binary files a/public/preview.jpg and b/public/preview.jpg differ diff --git a/public/site.webmanifest b/public/site.webmanifest index 6cd90611..3465bc83 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 00000000..d6672e60 --- /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 25b57f71..5e83dd06 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 00000000..6389ea0b --- /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 023a1bde..e710b0a2 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 3cfd0dba..749b13dc 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 7c40c6db..d718a956 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 006facf0..8fb6ae87 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 c04b4948..209ac6eb 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 4d00eb6f..d3f34186 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 894a7726..a97bbb6f 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 00000000..fb337670 --- /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 9e0d79aa..00000000 --- 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 290d4040..c2ec9b36 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 6d36bb2f..64d693ae 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 00000000..90ea0d3f --- /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 00000000..9f8412a5 --- /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 00000000..111bdd4e --- /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 75f1a455..8ffbb51c 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 3e690c8e..dd3a9fa6 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 72b98c8f..330af51b 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 cadaf5a7..d871652a 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 7b8d90c4..e18e55c8 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 622e5469..c843e78c 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 51bbc4e3..862ab374 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 739b3981..a9a67084 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 1ddf1f80..043e610f 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 a0dab71c..60d5c33e 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 f85ef179..2c2cc764 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 90a88006..ee7fd972 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 ea4f52b3..16d09429 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 e7b12416..bc85adad 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 56abf7d9..0933859c 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 60eabc86..15fe9a01 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 37f1ee18..6e6d7a95 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 be37d3d0..2c3357b0 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 683fe5f4..cd589dde 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 7ea9ac64..66286835 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 64aa7915..dcb7eea4 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 081a7a6d..0cc9c2e0 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 f19cbc20..14268e42 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 b603e7b5..fc9c2bf2 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 93bacc36..8103f966 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 7db6b946..8675898c 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 b549613f..27350007 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 00000000..d8a9eba1 --- /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 a0082c79..3ea148c0 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 7e71813e..867795e1 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 01fb1ddc..84de66cb 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 00000000..56a558a3 --- /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 00000000..c1db453e --- /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 3c73c001..88cfb274 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 40325a30..a783037a 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 aaad91a3..92ae5a76 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 00000000..ea481737 --- /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 78cfccc1..9631ad49 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 64c1af83..5ed0297d 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 c5aa7b04..94e6f761 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 c09cca28..a8214747 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 69c0e9c3..8406f13e 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 d9c455e2..28059297 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 b949fb95..b8123c90 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 6193e127..26ad4253 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 00000000..76cf94ec --- /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 e0d68421..caec6fde 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 6a0a12ab..0ff62940 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 8c9033f0..749148fb 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 ecba6689..e71acc4e 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 00000000..a0281e7e --- /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 867303f1..dd28a9f2 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 c095ced7..57def5a1 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 a67084d9..6b210cb6 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 efef45f6..2dc43d65 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 00000000..a44f254c --- /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 00000000..d238b4e1 --- /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 00000000..ddba1e3f --- /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 81e75134..39129f56 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 333bc243..1dd6452b 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"