mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 14:30:29 +03:00 
			
		
		
		
	Merge 9440e63dc9 into fbd7e0a14b
				
					
				
			This commit is contained in:
		
						commit
						d721d95be4
					
				
					 22 changed files with 1102 additions and 78 deletions
				
			
		
							
								
								
									
										4
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -4,4 +4,6 @@ node_modules
 | 
			
		|||
devAssets
 | 
			
		||||
 | 
			
		||||
.DS_Store
 | 
			
		||||
.idea
 | 
			
		||||
.idea
 | 
			
		||||
 | 
			
		||||
.env
 | 
			
		||||
| 
						 | 
				
			
			@ -10,6 +10,12 @@
 | 
			
		|||
  ],
 | 
			
		||||
  "allowCustomHomeservers": true,
 | 
			
		||||
 | 
			
		||||
  "pushNotificationDetails": {
 | 
			
		||||
    "pushNotifyUrl": "https://cinny.cc/_matrix/push/v1/notify",
 | 
			
		||||
    "vapidPublicKey": "BHLwykXs79AbKNiblEtZZRAgnt7o5_ieImhVJD8QZ01MVwAHnXwZzNgQEJJEU3E5CVsihoKtb7yaNe5x3vmkWkI",
 | 
			
		||||
    "webPushAppID": "cc.cinny.web"
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  "featuredCommunities": {
 | 
			
		||||
    "openAsDefault": false,
 | 
			
		||||
    "spaces": [
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										10
									
								
								docs/Caddyfile
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								docs/Caddyfile
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
(tls_cloudflare) {
 | 
			
		||||
    tls {
 | 
			
		||||
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
<URL-HERE> {
 | 
			
		||||
  import tls_cloudflare
 | 
			
		||||
  reverse_proxy sygnal:5000
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								docs/Dockerfile
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								docs/Dockerfile
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
FROM caddy:builder AS builder
 | 
			
		||||
 | 
			
		||||
RUN xcaddy build \
 | 
			
		||||
    --with github.com/caddy-dns/cloudflare
 | 
			
		||||
FROM caddy:latest
 | 
			
		||||
 | 
			
		||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
 | 
			
		||||
COPY Caddyfile /etc/caddy/Caddyfile
 | 
			
		||||
COPY .env /etc/caddy/.env
 | 
			
		||||
							
								
								
									
										1
									
								
								docs/sample.env
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								docs/sample.env
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
CLOUDFLARE_API_TOKEN=<TOKEN-HERE>
 | 
			
		||||
							
								
								
									
										308
									
								
								docs/sygnal-setup.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										308
									
								
								docs/sygnal-setup.md
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,308 @@
 | 
			
		|||
## Sygnal with Caddy & Cloudflare on Vultr
 | 
			
		||||
 | 
			
		||||
This document walks you through setting up a [Sygnal](https://github.com/matrix-org/sygnal) push gateway for Matrix, running in a Docker container. We will use [Caddy](https://caddyserver.com/) as a reverse proxy, also in Docker, to handle HTTPS automatically using DNS challenges with [Cloudflare](https://www.cloudflare.com/).
 | 
			
		||||
 | 
			
		||||
Now Cloudflare and Vultr have a deal in place where traffic from Cloudflare to Vultr and vice versa does not incur bandwidth usage. So you can pass endless amounts through without any extra billing. This is why the docs utilize Vultr, but you're free to use whatever cloud provider you want and not use Cloudflare if you so choose.
 | 
			
		||||
 | 
			
		||||
### Prerequisites
 | 
			
		||||
 | 
			
		||||
1.  **Vultr Server**: A running server instance. This guide assumes a fresh server running a common Linux distribution like Debian, Ubuntu, or Alpine.
 | 
			
		||||
2.  **Domain Name**: A domain name managed through Cloudflare.
 | 
			
		||||
3.  **Cloudflare Account**: Your domain must be using Cloudflare's DNS.
 | 
			
		||||
4.  **Docker & Docker Compose**: Docker and `docker-compose` must be installed on your Vultr server.
 | 
			
		||||
5.  **A Matrix Client**: A client like [Cinny](https://github.com/cinnyapp/cinny) that you want to point to your new push gateway.
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
### Step 1: Cloudflare Configuration
 | 
			
		||||
 | 
			
		||||
Before touching the server, we need to configure Cloudflare.
 | 
			
		||||
 | 
			
		||||
#### 1.1. DNS Record
 | 
			
		||||
 | 
			
		||||
In your Cloudflare dashboard, create an **A** (for IPv4) or **AAAA** (for IPv6) record for the subdomain you'll use for Sygnal. Point it to your Vultr server's IP address.
 | 
			
		||||
 | 
			
		||||
- **Type**: `A` or `AAAA`
 | 
			
		||||
- **Name**: `sygnal.your-domain.com` (or your chosen subdomain)
 | 
			
		||||
- **Content**: Your Vultr server's IP address
 | 
			
		||||
- **Proxy status**: **Proxied** (Orange Cloud). This is important for Caddy's setup.
 | 
			
		||||
 | 
			
		||||
#### 1.2. API Token
 | 
			
		||||
 | 
			
		||||
Caddy needs an API token to prove to Cloudflare that you own the domain so it can create the necessary DNS records for issuing an SSL certificate.
 | 
			
		||||
 | 
			
		||||
1.  Go to **My Profile** \> **API Tokens** in Cloudflare.
 | 
			
		||||
2.  Click **Create Token**.
 | 
			
		||||
3.  Use the **Edit zone DNS** template.
 | 
			
		||||
4.  Under **Permissions**, ensure `Zone:DNS:Edit` is selected.
 | 
			
		||||
5.  Under **Zone Resources**, select the specific zone for `your-domain.com`.
 | 
			
		||||
6.  Continue to summary and create the token.
 | 
			
		||||
7.  **Copy the generated token immediately.** You will not be able to see it again. We will use this as your `CLOUDFLARE_API_TOKEN`.
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
### Step 2: Server Preparation
 | 
			
		||||
 | 
			
		||||
#### 2.1. Connect to your Server (SSH)
 | 
			
		||||
 | 
			
		||||
If your Vultr instance uses an IPv6 address, connecting via SSH can sometimes be tricky. You can create an alias in your local `~/.ssh/config` file to make it easier.
 | 
			
		||||
 | 
			
		||||
Open or create `~/.ssh/config` on your local machine and add:
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
Host vultr-sygnal
 | 
			
		||||
    # Replace with your server's IPv6 or IPv4 address
 | 
			
		||||
    Hostname 2001:19f0:5400:1532:5400:05ff:fe78:fb25
 | 
			
		||||
    User root
 | 
			
		||||
    # For IPv6, uncomment the line below
 | 
			
		||||
    # AddressFamily inet6
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Now you can connect simply by typing `ssh vultr-sygnal`.
 | 
			
		||||
 | 
			
		||||
#### 2.2. Install Docker and Docker Compose
 | 
			
		||||
 | 
			
		||||
Follow the official Docker documentation to install the Docker Engine and Docker Compose for your server's operating system.
 | 
			
		||||
 | 
			
		||||
#### 2.3. Configure Firewall
 | 
			
		||||
 | 
			
		||||
We need to allow HTTP and HTTPS traffic so Caddy can obtain certificates and serve requests. If you are using `ufw`:
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
sudo ufw allow 80/tcp
 | 
			
		||||
sudo ufw allow 443/tcp
 | 
			
		||||
sudo ufw enable
 | 
			
		||||
sudo ufw status
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
### Step 3: Project Structure and Configuration
 | 
			
		||||
 | 
			
		||||
On your Vultr server, let's create a directory to hold all our configuration files.
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
mkdir -p /opt/matrix-sygnal
 | 
			
		||||
cd /opt/matrix-sygnal
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
We will create all subsequent files inside this `/opt/matrix-sygnal` directory.
 | 
			
		||||
 | 
			
		||||
#### 3.1. Sygnal VAPID Keys
 | 
			
		||||
 | 
			
		||||
WebPush requires a VAPID key pair. The private key stays on your server, and the public key is given to clients.
 | 
			
		||||
 | 
			
		||||
1.  **Generate the Private Key**:
 | 
			
		||||
    Use `openssl` to generate an EC private key.
 | 
			
		||||
 | 
			
		||||
    ```sh
 | 
			
		||||
    # This command needs to be run in the /opt/matrix-sygnal directory
 | 
			
		||||
    openssl ecparam -name prime256v1 -genkey -noout -out sygnal_private_key.pem
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
2.  **Extract the Public Key**:
 | 
			
		||||
    Extract the corresponding public key from the private key. You will need this for your client configuration later.
 | 
			
		||||
 | 
			
		||||
    ```sh
 | 
			
		||||
    # This command extracts the public key in the correct format
 | 
			
		||||
    openssl ec -in sygnal_private_key.pem -pubout -outform DER | tail -c 65 | base64 | tr '/+' '_-' | tr -d '='
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
    **Save the output of this command.** This is your `vapidPublicKey`. It should look similar to the one from the `cinny.cc` example.
 | 
			
		||||
 | 
			
		||||
#### 3.2. Sygnal Configuration (`sygnal.yaml`)
 | 
			
		||||
 | 
			
		||||
Create a file named `sygnal.yaml`. This file tells Sygnal how to run.
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
# /opt/matrix-sygnal/sygnal.yaml
 | 
			
		||||
http:
 | 
			
		||||
  bind_addresses: ['0.0.0.0']
 | 
			
		||||
  port: 5000
 | 
			
		||||
 | 
			
		||||
# This is where we configure our push gateway app
 | 
			
		||||
apps:
 | 
			
		||||
  # This app_id must match the one used in your client's configuration
 | 
			
		||||
  cc.cinny.web:
 | 
			
		||||
    type: webpush
 | 
			
		||||
    # This path is *inside the container*. We will map our generated key to it.
 | 
			
		||||
    vapid_private_key: /data/private_key.pem
 | 
			
		||||
    # An email for VAPID contact details
 | 
			
		||||
    vapid_contact_email: mailto:your-email@your-domain.com
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### 3.3. Caddy Configuration (`Caddyfile`)
 | 
			
		||||
 | 
			
		||||
Create a file named `Caddyfile`. This tells Caddy how to proxy requests.
 | 
			
		||||
 | 
			
		||||
**Replace `sygnal.your-domain.com`** with the domain you configured in Step 1.
 | 
			
		||||
 | 
			
		||||
```caddyfile
 | 
			
		||||
# /opt/matrix-sygnal/Caddyfile
 | 
			
		||||
 | 
			
		||||
# Reusable snippet for Cloudflare TLS
 | 
			
		||||
(tls_cloudflare) {
 | 
			
		||||
    tls {
 | 
			
		||||
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Your public-facing URL
 | 
			
		||||
sygnal.your-domain.com {
 | 
			
		||||
  # Get an SSL certificate from Let's Encrypt using the Cloudflare DNS challenge
 | 
			
		||||
  import tls_cloudflare
 | 
			
		||||
 | 
			
		||||
  # Log requests to standard output
 | 
			
		||||
  log
 | 
			
		||||
 | 
			
		||||
  # Reverse proxy requests to the sygnal container on port 5000
 | 
			
		||||
  # 'sygnal' is the service name we will define in docker-compose.yml
 | 
			
		||||
  reverse_proxy sygnal:5000
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### 3.4. Caddy Dockerfile
 | 
			
		||||
 | 
			
		||||
While you can use the standard `caddy:latest` image, you need one with the Cloudflare DNS provider plugin. Create a file named `Dockerfile` for Caddy.
 | 
			
		||||
 | 
			
		||||
```dockerfile
 | 
			
		||||
# /opt/matrix-sygnal/Dockerfile
 | 
			
		||||
FROM caddy:builder AS builder
 | 
			
		||||
 | 
			
		||||
RUN xcaddy build \
 | 
			
		||||
    --with github.com/caddy-dns/cloudflare
 | 
			
		||||
 | 
			
		||||
FROM caddy:latest
 | 
			
		||||
 | 
			
		||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### 3.5. Environment File (`.env`)
 | 
			
		||||
 | 
			
		||||
Create a file named `.env` to securely store your Cloudflare API Token.
 | 
			
		||||
 | 
			
		||||
```.env
 | 
			
		||||
# /opt/matrix-sygnal/.env
 | 
			
		||||
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token-from-step-1
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
### Step 4: Docker Compose
 | 
			
		||||
 | 
			
		||||
Using `docker-compose` simplifies managing our multi-container application. Create a `docker-compose.yml` file.
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
# /opt/matrix-sygnal/docker-compose.yml
 | 
			
		||||
version: '3.7'
 | 
			
		||||
 | 
			
		||||
services:
 | 
			
		||||
  caddy:
 | 
			
		||||
    # Build the Caddy image from our Dockerfile in the current directory
 | 
			
		||||
    build: .
 | 
			
		||||
    container_name: caddy
 | 
			
		||||
    hostname: caddy
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    networks:
 | 
			
		||||
      - matrix
 | 
			
		||||
    ports:
 | 
			
		||||
      # Expose standard web ports to the host
 | 
			
		||||
      - '80:80'
 | 
			
		||||
      - '443:443'
 | 
			
		||||
    volumes:
 | 
			
		||||
      # Mount the Caddyfile into the container
 | 
			
		||||
      - ./Caddyfile:/etc/caddy/Caddyfile
 | 
			
		||||
      # Create a volume for Caddy's data (certs, etc.)
 | 
			
		||||
      - caddy_data:/data
 | 
			
		||||
    # Load the Cloudflare token from the .env file
 | 
			
		||||
    env_file:
 | 
			
		||||
      - ./.env
 | 
			
		||||
 | 
			
		||||
  sygnal:
 | 
			
		||||
    # Use the official Sygnal image
 | 
			
		||||
    image: matrixdotorg/sygnal:latest
 | 
			
		||||
    container_name: sygnal
 | 
			
		||||
    hostname: sygnal
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    networks:
 | 
			
		||||
      - matrix
 | 
			
		||||
    volumes:
 | 
			
		||||
      # Mount the Sygnal config file
 | 
			
		||||
      - ./sygnal.yaml:/sygnal.yaml
 | 
			
		||||
      # Mount the generated private key to the path specified in sygnal.yaml
 | 
			
		||||
      - ./sygnal_private_key.pem:/data/private_key.pem
 | 
			
		||||
      # Create a volume for any other data Sygnal might store
 | 
			
		||||
      - sygnal_data:/data
 | 
			
		||||
    command: ['--config-path=/sygnal.yaml']
 | 
			
		||||
 | 
			
		||||
volumes:
 | 
			
		||||
  caddy_data:
 | 
			
		||||
  sygnal_data:
 | 
			
		||||
 | 
			
		||||
networks:
 | 
			
		||||
  matrix:
 | 
			
		||||
    driver: bridge
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
### Step 5: Launch the Services
 | 
			
		||||
 | 
			
		||||
Your directory `/opt/matrix-sygnal` should now look like this:
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
/opt/matrix-sygnal/
 | 
			
		||||
├── Caddyfile
 | 
			
		||||
├── docker-compose.yml
 | 
			
		||||
├── Dockerfile
 | 
			
		||||
├── .env
 | 
			
		||||
├── sygnal.yaml
 | 
			
		||||
└── sygnal_private_key.pem
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Now, you can build and run everything with a single command:
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
cd /opt/matrix-sygnal
 | 
			
		||||
sudo docker-compose up --build -d
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
- `--build` tells Docker Compose to build the Caddy image from your `Dockerfile`.
 | 
			
		||||
- `-d` runs the containers in detached mode (in the background).
 | 
			
		||||
 | 
			
		||||
To check the status and logs:
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
# See if containers are running
 | 
			
		||||
sudo docker-compose ps
 | 
			
		||||
 | 
			
		||||
# View the live logs for both services
 | 
			
		||||
sudo docker-compose logs -f
 | 
			
		||||
 | 
			
		||||
# View logs for a specific service (e.g., caddy)
 | 
			
		||||
sudo docker-compose logs -f caddy
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Caddy will automatically start, obtain an SSL certificate for `sygnal.your-domain.com`, and begin proxying requests to the Sygnal container.
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
### Step 6: Client Configuration
 | 
			
		||||
 | 
			
		||||
The final step is to configure your Matrix client to use your new push gateway. In Cinny, for example, you would modify its `config.json` or use a homeserver that advertises these settings.
 | 
			
		||||
 | 
			
		||||
Update the `pushNotificationDetails` section with the information from your server:
 | 
			
		||||
 | 
			
		||||
```json
 | 
			
		||||
"pushNotificationDetails": {
 | 
			
		||||
    "pushNotifyUrl": "https://sygnal.your-domain.com/_matrix/push/v1/notify",
 | 
			
		||||
    "vapidPublicKey": "YOUR_VAPID_PUBLIC_KEY_FROM_STEP_3.1",
 | 
			
		||||
    "webPushAppID": "cc.cinny.web"
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
- **`pushNotifyUrl`**: The public URL of your new Sygnal instance.
 | 
			
		||||
- **`vapidPublicKey`**: The public key you generated in step 3.1.
 | 
			
		||||
- **`webPushAppID`**: The application ID you defined in your `sygnal.yaml`. This must match exactly.
 | 
			
		||||
 | 
			
		||||
After configuring your client, it will register for push notifications with your Sygnal instance, which will then handle delivering them.
 | 
			
		||||
							
								
								
									
										9
									
								
								docs/sygnal.yaml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								docs/sygnal.yaml
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
http:
 | 
			
		||||
  bind_addresses: ['0.0.0.0']
 | 
			
		||||
  port: 5000
 | 
			
		||||
 | 
			
		||||
apps:
 | 
			
		||||
  cc.cinny.web:
 | 
			
		||||
    type: webpush
 | 
			
		||||
    vapid_private_key: /data/private_key.pem
 | 
			
		||||
    vapid_contact_email: help@cinny.cc
 | 
			
		||||
							
								
								
									
										4
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -102,7 +102,8 @@
 | 
			
		|||
        "vite": "5.4.19",
 | 
			
		||||
        "vite-plugin-pwa": "0.20.5",
 | 
			
		||||
        "vite-plugin-static-copy": "1.0.4",
 | 
			
		||||
        "vite-plugin-top-level-await": "1.4.4"
 | 
			
		||||
        "vite-plugin-top-level-await": "1.4.4",
 | 
			
		||||
        "workbox-precaching": "7.3.0"
 | 
			
		||||
      },
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">=16.0.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -12212,6 +12213,7 @@
 | 
			
		|||
      "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.3.0.tgz",
 | 
			
		||||
      "integrity": "sha512-ckp/3t0msgXclVAYaNndAGeAoWQUv7Rwc4fdhWL69CCAb2UHo3Cef0KIUctqfQj1p8h6aGyz3w8Cy3Ihq9OmIw==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "workbox-core": "7.3.0",
 | 
			
		||||
        "workbox-routing": "7.3.0",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -113,6 +113,7 @@
 | 
			
		|||
    "vite": "5.4.19",
 | 
			
		||||
    "vite-plugin-pwa": "0.20.5",
 | 
			
		||||
    "vite-plugin-static-copy": "1.0.4",
 | 
			
		||||
    "vite-plugin-top-level-await": "1.4.4"
 | 
			
		||||
    "vite-plugin-top-level-await": "1.4.4",
 | 
			
		||||
    "workbox-precaching": "7.3.0"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,149 @@
 | 
			
		|||
import React, { useState } from 'react';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
  color,
 | 
			
		||||
  config,
 | 
			
		||||
  Dialog,
 | 
			
		||||
  Header,
 | 
			
		||||
  Icon,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Text,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { useAtom } from 'jotai';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { useSetting } from '../../../state/hooks/settings';
 | 
			
		||||
import { settingsAtom } from '../../../state/settings';
 | 
			
		||||
import { pushSubscriptionAtom } from '../../../state/pushSubscription';
 | 
			
		||||
import { deRegisterAllPushers } from './PushNotifications';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
 | 
			
		||||
type ConfirmDeregisterDialogProps = {
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  onConfirm: () => void;
 | 
			
		||||
  isLoading: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function ConfirmDeregisterDialog({ onClose, onConfirm, isLoading }: ConfirmDeregisterDialogProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
      <OverlayCenter>
 | 
			
		||||
        <FocusTrap
 | 
			
		||||
          focusTrapOptions={{
 | 
			
		||||
            clickOutsideDeactivates: true,
 | 
			
		||||
            onDeactivate: onClose,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Dialog variant="Surface">
 | 
			
		||||
            <Header style={{ padding: `0 ${config.space.S400}` }} variant="Surface" size="500">
 | 
			
		||||
              <Box grow="Yes">
 | 
			
		||||
                <Text size="H4">Reset All Push Notifications</Text>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <IconButton size="300" radii="300" onClick={onClose} disabled={isLoading}>
 | 
			
		||||
                <Icon size="100" src={Icons.Cross} />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            </Header>
 | 
			
		||||
            <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
              <Text>
 | 
			
		||||
                This will remove push notifications from all your sessions and devices. This action
 | 
			
		||||
                cannot be undone. Are you sure you want to continue?
 | 
			
		||||
              </Text>
 | 
			
		||||
              <Box direction="Column" gap="200" style={{ paddingTop: config.space.S200 }}>
 | 
			
		||||
                <Button
 | 
			
		||||
                  variant="Critical"
 | 
			
		||||
                  fill="Solid"
 | 
			
		||||
                  onClick={onConfirm}
 | 
			
		||||
                  disabled={isLoading}
 | 
			
		||||
                  before={isLoading && <Spinner size="100" variant="Critical" />}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="B400">Reset All</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
                <Button variant="Secondary" fill="Soft" onClick={onClose} disabled={isLoading}>
 | 
			
		||||
                  <Text size="B400">Cancel</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Dialog>
 | 
			
		||||
        </FocusTrap>
 | 
			
		||||
      </OverlayCenter>
 | 
			
		||||
    </Overlay>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function DeregisterAllPushersSetting() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [deregisterState] = useAsyncCallback(deRegisterAllPushers);
 | 
			
		||||
  const [isConfirming, setIsConfirming] = useState(false);
 | 
			
		||||
  const [usePushNotifications, setPushNotifications] = useSetting(
 | 
			
		||||
    settingsAtom,
 | 
			
		||||
    'usePushNotifications'
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const [pushSubscription, setPushSubscription] = useAtom(pushSubscriptionAtom);
 | 
			
		||||
 | 
			
		||||
  const handleOpenConfirmDialog = () => {
 | 
			
		||||
    setIsConfirming(true);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleCloseConfirmDialog = () => {
 | 
			
		||||
    if (deregisterState.status === AsyncStatus.Loading) return;
 | 
			
		||||
    setIsConfirming(false);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleConfirmDeregister = async () => {
 | 
			
		||||
    await deRegisterAllPushers(mx);
 | 
			
		||||
    setPushNotifications(false);
 | 
			
		||||
    setPushSubscription(null);
 | 
			
		||||
    setIsConfirming(false);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {isConfirming && (
 | 
			
		||||
        <ConfirmDeregisterDialog
 | 
			
		||||
          onClose={handleCloseConfirmDialog}
 | 
			
		||||
          onConfirm={handleConfirmDeregister}
 | 
			
		||||
          isLoading={deregisterState.status === AsyncStatus.Loading}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <SettingTile
 | 
			
		||||
        title="Reset all push notifications"
 | 
			
		||||
        description={
 | 
			
		||||
          <div>
 | 
			
		||||
            <Text>
 | 
			
		||||
              This will remove push notifications from all your sessions/devices. You will need to
 | 
			
		||||
              re-enable them on each device individually.
 | 
			
		||||
            </Text>
 | 
			
		||||
            {deregisterState.status === AsyncStatus.Error && (
 | 
			
		||||
              <Text as="span" style={{ color: color.Critical.Main }} size="T200">
 | 
			
		||||
                <br />
 | 
			
		||||
                Failed to deregister devices. Please try again.
 | 
			
		||||
              </Text>
 | 
			
		||||
            )}
 | 
			
		||||
            {deregisterState.status === AsyncStatus.Success && (
 | 
			
		||||
              <Text as="span" style={{ color: color.Success.Main }} size="T200">
 | 
			
		||||
                <br />
 | 
			
		||||
                Successfully deregistered all devices.
 | 
			
		||||
              </Text>
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
        }
 | 
			
		||||
        after={
 | 
			
		||||
          <Button size="300" radii="300" onClick={handleOpenConfirmDialog}>
 | 
			
		||||
            <Text size="B300" style={{ color: color.Critical.Main }}>
 | 
			
		||||
              Reset All
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										155
									
								
								src/app/features/settings/notifications/PushNotifications.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								src/app/features/settings/notifications/PushNotifications.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,155 @@
 | 
			
		|||
import { MatrixClient } from 'matrix-js-sdk';
 | 
			
		||||
import { ClientConfig } from '../../../hooks/useClientConfig';
 | 
			
		||||
 | 
			
		||||
export async function requestBrowserNotificationPermission(): Promise<NotificationPermission> {
 | 
			
		||||
  if (!('Notification' in window)) {
 | 
			
		||||
    return 'denied';
 | 
			
		||||
  }
 | 
			
		||||
  try {
 | 
			
		||||
    const permission: NotificationPermission = await Notification.requestPermission();
 | 
			
		||||
    return permission;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error requesting notification permission:', error);
 | 
			
		||||
    return 'denied';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function enablePushNotifications(
 | 
			
		||||
  mx: MatrixClient,
 | 
			
		||||
  clientConfig: ClientConfig,
 | 
			
		||||
  pushSubscriptionAtom: Atom<PushSubscriptionJSON | null, [PushSubscription | null], void>
 | 
			
		||||
): Promise<void> {
 | 
			
		||||
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
 | 
			
		||||
    throw new Error('Push messaging is not supported in this browser.');
 | 
			
		||||
  }
 | 
			
		||||
  const [pushSubAtom, setPushSubscription] = pushSubscriptionAtom;
 | 
			
		||||
  const registration = await navigator.serviceWorker.ready;
 | 
			
		||||
  const currentBrowserSub = await registration.pushManager.getSubscription();
 | 
			
		||||
 | 
			
		||||
  /* Self-Healing Check. Effectively checks if the browser has invalidated our subscription and recreates it 
 | 
			
		||||
     only when necessary. This prevents us from needing an external call to get back the web push info. 
 | 
			
		||||
  */
 | 
			
		||||
  if (currentBrowserSub && pushSubAtom && currentBrowserSub.endpoint === pushSubAtom.endpoint) {
 | 
			
		||||
    console.error('Valid saved subscription found. Ensuring pusher is enabled on homeserver...');
 | 
			
		||||
    const pusherData = {
 | 
			
		||||
      kind: 'http' as const,
 | 
			
		||||
      app_id: clientConfig.pushNotificationDetails?.webPushAppID,
 | 
			
		||||
      pushkey: pushSubAtom.keys!.p256dh!,
 | 
			
		||||
      app_display_name: 'Cinny',
 | 
			
		||||
      device_display_name: 'This Browser',
 | 
			
		||||
      lang: navigator.language || 'en',
 | 
			
		||||
      data: {
 | 
			
		||||
        url: clientConfig.pushNotificationDetails?.pushNotifyUrl,
 | 
			
		||||
        format: 'event_id_only' as const,
 | 
			
		||||
        endpoint: pushSubAtom.endpoint,
 | 
			
		||||
        p256dh: pushSubAtom.keys!.p256dh!,
 | 
			
		||||
        auth: pushSubAtom.keys!.auth!,
 | 
			
		||||
      },
 | 
			
		||||
      append: false,
 | 
			
		||||
    };
 | 
			
		||||
    navigator.serviceWorker.controller?.postMessage({
 | 
			
		||||
      url: mx.baseUrl,
 | 
			
		||||
      type: 'togglePush',
 | 
			
		||||
      pusherData,
 | 
			
		||||
      token: mx.getAccessToken(),
 | 
			
		||||
    });
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  console.error('No valid saved subscription. Performing full, new subscription...');
 | 
			
		||||
 | 
			
		||||
  if (currentBrowserSub) {
 | 
			
		||||
    await currentBrowserSub.unsubscribe();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const newSubscription = await registration.pushManager.subscribe({
 | 
			
		||||
    userVisibleOnly: true,
 | 
			
		||||
    applicationServerKey: clientConfig.pushNotificationDetails?.vapidPublicKey,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  setPushSubscription(newSubscription);
 | 
			
		||||
 | 
			
		||||
  const subJson = newSubscription.toJSON();
 | 
			
		||||
  const pusherData = {
 | 
			
		||||
    kind: 'http' as const,
 | 
			
		||||
    app_id: clientConfig.pushNotificationDetails?.webPushAppID,
 | 
			
		||||
    pushkey: subJson.keys!.p256dh!,
 | 
			
		||||
    app_display_name: 'Cinny',
 | 
			
		||||
    device_display_name:
 | 
			
		||||
      (await mx.getDevice(mx.getDeviceId() ?? '')).display_name ?? 'Unknown Device',
 | 
			
		||||
    lang: navigator.language || 'en',
 | 
			
		||||
    data: {
 | 
			
		||||
      url: clientConfig.pushNotificationDetails?.pushNotifyUrl,
 | 
			
		||||
      format: 'event_id_only' as const,
 | 
			
		||||
      endpoint: newSubscription.endpoint,
 | 
			
		||||
      p256dh: subJson.keys!.p256dh!,
 | 
			
		||||
      auth: subJson.keys!.auth!,
 | 
			
		||||
    },
 | 
			
		||||
    append: false,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  navigator.serviceWorker.controller?.postMessage({
 | 
			
		||||
    url: mx.baseUrl,
 | 
			
		||||
    type: 'togglePush',
 | 
			
		||||
    pusherData,
 | 
			
		||||
    token: mx.getAccessToken(),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Disables push notifications by telling the homeserver to delete the pusher,
 | 
			
		||||
 * but keeps the browser subscription locally for a fast re-enable.
 | 
			
		||||
 */
 | 
			
		||||
export async function disablePushNotifications(
 | 
			
		||||
  mx: MatrixClient,
 | 
			
		||||
  clientConfig: ClientConfig,
 | 
			
		||||
  pushSubscriptionAtom: Atom<PushSubscriptionJSON | null, [PushSubscription | null], void>
 | 
			
		||||
): Promise<void> {
 | 
			
		||||
  const [pushSubAtom] = pushSubscriptionAtom;
 | 
			
		||||
 | 
			
		||||
  const pusherData = {
 | 
			
		||||
    kind: null,
 | 
			
		||||
    app_id: clientConfig.pushNotificationDetails?.webPushAppID,
 | 
			
		||||
    pushkey: pushSubAtom?.keys?.p256dh,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  navigator.serviceWorker.controller?.postMessage({
 | 
			
		||||
    url: mx.baseUrl,
 | 
			
		||||
    type: 'togglePush',
 | 
			
		||||
    pusherData,
 | 
			
		||||
    token: mx.getAccessToken(),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function deRegisterAllPushers(mx: MatrixClient): Promise<void> {
 | 
			
		||||
  const response = await mx.getPushers();
 | 
			
		||||
  const pushers = response.pushers || [];
 | 
			
		||||
  if (pushers.length === 0) return;
 | 
			
		||||
 | 
			
		||||
  const deletionPromises = pushers.map((pusher) => {
 | 
			
		||||
    const pusherToDelete = {
 | 
			
		||||
      kind: null,
 | 
			
		||||
      app_id: pusher.app_id,
 | 
			
		||||
      pushkey: pusher.pushkey,
 | 
			
		||||
    };
 | 
			
		||||
    return mx.setPusher(pusherToDelete as any);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await Promise.allSettled(deletionPromises);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function togglePusher(
 | 
			
		||||
  mx: MatrixClient,
 | 
			
		||||
  clientConfig: ClientConfig,
 | 
			
		||||
  visible: boolean,
 | 
			
		||||
  usePushNotifications: boolean,
 | 
			
		||||
  pushSubscriptionAtom: Atom<PushSubscriptionJSON | null, [PushSubscription | null], void>
 | 
			
		||||
): Promise<void> {
 | 
			
		||||
  if (usePushNotifications) {
 | 
			
		||||
    if (visible) {
 | 
			
		||||
      await disablePushNotifications(mx, clientConfig, pushSubscriptionAtom);
 | 
			
		||||
    } else {
 | 
			
		||||
      await enablePushNotifications(mx, clientConfig, pushSubscriptionAtom);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,8 @@
 | 
			
		|||
import React, { useCallback } from 'react';
 | 
			
		||||
/* eslint-disable no-nested-ternary */
 | 
			
		||||
import React, { useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
 | 
			
		||||
import { IPusherRequest } from 'matrix-js-sdk';
 | 
			
		||||
import { useAtom } from 'jotai';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
| 
						 | 
				
			
			@ -10,6 +12,14 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm
 | 
			
		|||
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import {
 | 
			
		||||
  requestBrowserNotificationPermission,
 | 
			
		||||
  enablePushNotifications,
 | 
			
		||||
  disablePushNotifications,
 | 
			
		||||
} from './PushNotifications';
 | 
			
		||||
import { useClientConfig } from '../../../hooks/useClientConfig';
 | 
			
		||||
import { pushSubscriptionAtom } from '../../../state/pushSubscription';
 | 
			
		||||
import { DeregisterAllPushersSetting } from './DeregisterPushNotifications';
 | 
			
		||||
 | 
			
		||||
function EmailNotification() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
| 
						 | 
				
			
			@ -84,21 +94,93 @@ function EmailNotification() {
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function WebPushNotificationSetting() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const clientConfig = useClientConfig();
 | 
			
		||||
  const [isLoading, setIsLoading] = useState<boolean>(true);
 | 
			
		||||
  const [usePushNotifications, setPushNotifications] = useSetting(
 | 
			
		||||
    settingsAtom,
 | 
			
		||||
    'usePushNotifications'
 | 
			
		||||
  );
 | 
			
		||||
  const pushSubAtom = useAtom(pushSubscriptionAtom);
 | 
			
		||||
 | 
			
		||||
  const browserPermission = usePermissionState('notifications', getNotificationState());
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setIsLoading(false);
 | 
			
		||||
  }, []);
 | 
			
		||||
  const handleRequestPermissionAndEnable = async () => {
 | 
			
		||||
    setIsLoading(true);
 | 
			
		||||
    try {
 | 
			
		||||
      const permissionResult = await requestBrowserNotificationPermission();
 | 
			
		||||
      if (permissionResult === 'granted') {
 | 
			
		||||
        await enablePushNotifications(mx, clientConfig, pushSubAtom);
 | 
			
		||||
        setPushNotifications(true);
 | 
			
		||||
      }
 | 
			
		||||
    } finally {
 | 
			
		||||
      setIsLoading(false);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handlePushSwitchChange = async (wantsPush: boolean) => {
 | 
			
		||||
    setIsLoading(true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      if (wantsPush) {
 | 
			
		||||
        await enablePushNotifications(mx, clientConfig, pushSubAtom);
 | 
			
		||||
      } else {
 | 
			
		||||
        await disablePushNotifications(mx, clientConfig, pushSubAtom);
 | 
			
		||||
      }
 | 
			
		||||
      setPushNotifications(wantsPush);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setIsLoading(false);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title="Background Push Notifications"
 | 
			
		||||
      description={
 | 
			
		||||
        browserPermission === 'denied' ? (
 | 
			
		||||
          <Text as="span" style={{ color: color.Critical.Main }} size="T200">
 | 
			
		||||
            Permission blocked. Please allow notifications in your browser settings.
 | 
			
		||||
          </Text>
 | 
			
		||||
        ) : (
 | 
			
		||||
          'Receive notifications when the app is closed or in the background.'
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
      after={
 | 
			
		||||
        isLoading ? (
 | 
			
		||||
          <Spinner variant="Secondary" />
 | 
			
		||||
        ) : browserPermission === 'prompt' ? (
 | 
			
		||||
          <Button size="300" radii="300" onClick={handleRequestPermissionAndEnable}>
 | 
			
		||||
            <Text size="B300">Enable</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        ) : browserPermission === 'granted' ? (
 | 
			
		||||
          <Switch value={usePushNotifications} onChange={handlePushSwitchChange} />
 | 
			
		||||
        ) : null
 | 
			
		||||
      }
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function SystemNotification() {
 | 
			
		||||
  const notifPermission = usePermissionState('notifications', getNotificationState());
 | 
			
		||||
  const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
 | 
			
		||||
  const [showInAppNotifs, setShowInAppNotifs] = useSetting(settingsAtom, 'useInAppNotifications');
 | 
			
		||||
  const [isNotificationSounds, setIsNotificationSounds] = useSetting(
 | 
			
		||||
    settingsAtom,
 | 
			
		||||
    'isNotificationSounds'
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const requestNotificationPermission = () => {
 | 
			
		||||
    window.Notification.requestPermission();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">System</Text>
 | 
			
		||||
      <Text size="L400">System & Notifications</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <WebPushNotificationSetting />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
| 
						 | 
				
			
			@ -106,31 +188,9 @@ export function SystemNotification() {
 | 
			
		|||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Desktop Notifications"
 | 
			
		||||
          description={
 | 
			
		||||
            notifPermission === 'denied' ? (
 | 
			
		||||
              <Text as="span" style={{ color: color.Critical.Main }} size="T200">
 | 
			
		||||
                {'Notification' in window
 | 
			
		||||
                  ? 'Notification permission is blocked. Please allow notification permission from browser address bar.'
 | 
			
		||||
                  : 'Notifications are not supported by the system.'}
 | 
			
		||||
              </Text>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <span>Show desktop notifications when message arrive.</span>
 | 
			
		||||
            )
 | 
			
		||||
          }
 | 
			
		||||
          after={
 | 
			
		||||
            notifPermission === 'prompt' ? (
 | 
			
		||||
              <Button size="300" radii="300" onClick={requestNotificationPermission}>
 | 
			
		||||
                <Text size="B300">Enable</Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <Switch
 | 
			
		||||
                disabled={notifPermission !== 'granted'}
 | 
			
		||||
                value={showNotifications}
 | 
			
		||||
                onChange={setShowNotifications}
 | 
			
		||||
              />
 | 
			
		||||
            )
 | 
			
		||||
          }
 | 
			
		||||
          title="In-App Notifications"
 | 
			
		||||
          description="Show a notification when a message arrives while the app is open (but not focused on the room)."
 | 
			
		||||
          after={<Switch value={showInAppNotifs} onChange={setShowInAppNotifs} />}
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
| 
						 | 
				
			
			@ -141,7 +201,7 @@ export function SystemNotification() {
 | 
			
		|||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Notification Sound"
 | 
			
		||||
          description="Play sound when new message arrive."
 | 
			
		||||
          description="Play sound when new message arrives and app is open."
 | 
			
		||||
          after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
| 
						 | 
				
			
			@ -153,6 +213,15 @@ export function SystemNotification() {
 | 
			
		|||
      >
 | 
			
		||||
        <EmailNotification />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <DeregisterAllPushersSetting />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										45
									
								
								src/app/hooks/useAppVisibility.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/app/hooks/useAppVisibility.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,45 @@
 | 
			
		|||
import { useEffect } from 'react';
 | 
			
		||||
import { MatrixClient } from 'matrix-js-sdk';
 | 
			
		||||
import { useAtom } from 'jotai';
 | 
			
		||||
import { togglePusher } from '../features/settings/notifications/PushNotifications';
 | 
			
		||||
import { appEvents } from '../utils/appEvents';
 | 
			
		||||
import { useClientConfig } from './useClientConfig';
 | 
			
		||||
import { useSetting } from '../state/hooks/settings';
 | 
			
		||||
import { settingsAtom } from '../state/settings';
 | 
			
		||||
import { pushSubscriptionAtom } from '../state/pushSubscription';
 | 
			
		||||
 | 
			
		||||
export function useAppVisibility(mx: MatrixClient | undefined) {
 | 
			
		||||
  const clientConfig = useClientConfig();
 | 
			
		||||
  const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
 | 
			
		||||
  const pushSubAtom = useAtom(pushSubscriptionAtom);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const handleVisibilityChange = () => {
 | 
			
		||||
      const isVisible = document.visibilityState === 'visible';
 | 
			
		||||
      appEvents.onVisibilityChange?.(isVisible);
 | 
			
		||||
      if (!isVisible) {
 | 
			
		||||
        appEvents.onVisibilityHidden?.();
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    document.addEventListener('visibilitychange', handleVisibilityChange);
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      document.removeEventListener('visibilitychange', handleVisibilityChange);
 | 
			
		||||
    };
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!mx) return;
 | 
			
		||||
 | 
			
		||||
    const handleVisibilityForNotifications = (isVisible: boolean) => {
 | 
			
		||||
      togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    appEvents.onVisibilityChange = handleVisibilityForNotifications;
 | 
			
		||||
    // eslint-disable-next-line consistent-return
 | 
			
		||||
    return () => {
 | 
			
		||||
      appEvents.onVisibilityChange = null;
 | 
			
		||||
    };
 | 
			
		||||
  }, [mx, clientConfig, usePushNotifications, pushSubAtom]);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -10,6 +10,12 @@ export type ClientConfig = {
 | 
			
		|||
  homeserverList?: string[];
 | 
			
		||||
  allowCustomHomeservers?: boolean;
 | 
			
		||||
 | 
			
		||||
  pushNotificationDetails?: {
 | 
			
		||||
    pushNotifyUrl?: string;
 | 
			
		||||
    vapidPublicKey?: string;
 | 
			
		||||
    webPushAppID?: string;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  featuredCommunities?: {
 | 
			
		||||
    openAsDefault?: boolean;
 | 
			
		||||
    spaces?: string[];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,7 +21,6 @@ const getEventReaders = (room: Room, evtId?: string) => {
 | 
			
		|||
 | 
			
		||||
export const useRoomEventReaders = (room: Room, eventId?: string): string[] => {
 | 
			
		||||
  const [readers, setReaders] = useState<string[]>(() => getEventReaders(room, eventId));
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setReaders(getEventReaders(room, eventId));
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -46,6 +45,7 @@ export const useRoomEventReaders = (room: Room, eventId?: string): string[] => {
 | 
			
		|||
 | 
			
		||||
    room.on(RoomEvent.Receipt, handleReceipt);
 | 
			
		||||
    room.on(RoomEvent.LocalEchoUpdated, handleLocalEcho);
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      room.removeListener(RoomEvent.Receipt, handleReceipt);
 | 
			
		||||
      room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEcho);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -57,7 +57,11 @@ function FaviconUpdater() {
 | 
			
		|||
  useEffect(() => {
 | 
			
		||||
    let notification = false;
 | 
			
		||||
    let highlight = false;
 | 
			
		||||
    let total = 0;
 | 
			
		||||
    roomToUnread.forEach((unread) => {
 | 
			
		||||
      if (unread.from === null) {
 | 
			
		||||
        total += unread.total;
 | 
			
		||||
      }
 | 
			
		||||
      if (unread.total > 0) {
 | 
			
		||||
        notification = true;
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			@ -71,6 +75,11 @@ function FaviconUpdater() {
 | 
			
		|||
    } else {
 | 
			
		||||
      setFavicon(LogoSVG);
 | 
			
		||||
    }
 | 
			
		||||
    try {
 | 
			
		||||
      navigator.setAppBadge(total);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      // Likely Firefox/Gecko-based and doesn't support badging API
 | 
			
		||||
    }
 | 
			
		||||
  }, [roomToUnread]);
 | 
			
		||||
 | 
			
		||||
  return null;
 | 
			
		||||
| 
						 | 
				
			
			@ -83,7 +92,7 @@ function InviteNotifications() {
 | 
			
		|||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
  const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
 | 
			
		||||
  const [showNotifications] = useSetting(settingsAtom, 'useInAppNotifications');
 | 
			
		||||
  const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
 | 
			
		||||
 | 
			
		||||
  const notify = useCallback(
 | 
			
		||||
| 
						 | 
				
			
			@ -134,7 +143,7 @@ function MessageNotifications() {
 | 
			
		|||
  const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
 | 
			
		||||
  const [showNotifications] = useSetting(settingsAtom, 'useInAppNotifications');
 | 
			
		||||
  const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
| 
						 | 
				
			
			@ -187,6 +196,7 @@ function MessageNotifications() {
 | 
			
		|||
    ) => {
 | 
			
		||||
      if (mx.getSyncState() !== 'SYNCING') return;
 | 
			
		||||
      if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        !room ||
 | 
			
		||||
        !data.liveEvent ||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -37,6 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		|||
import { useSyncState } from '../../hooks/useSyncState';
 | 
			
		||||
import { stopPropagation } from '../../utils/keyboard';
 | 
			
		||||
import { SyncStatus } from './SyncStatus';
 | 
			
		||||
import { useAppVisibility } from '../../hooks/useAppVisibility';
 | 
			
		||||
 | 
			
		||||
function ClientRootLoading() {
 | 
			
		||||
  return (
 | 
			
		||||
| 
						 | 
				
			
			@ -156,6 +157,7 @@ export function ClientRoot({ children }: ClientRootProps) {
 | 
			
		|||
  );
 | 
			
		||||
 | 
			
		||||
  useLogoutListener(mx);
 | 
			
		||||
  useAppVisibility(mx);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (loadState.status === AsyncStatus.Idle) {
 | 
			
		||||
| 
						 | 
				
			
			@ -177,7 +179,6 @@ export function ClientRoot({ children }: ClientRootProps) {
 | 
			
		|||
      }
 | 
			
		||||
    }, [])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SpecVersions baseUrl={baseUrl!}>
 | 
			
		||||
      {mx && <SyncStatus mx={mx} />}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										32
									
								
								src/app/state/pushSubscription.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/app/state/pushSubscription.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
import { atom } from 'jotai';
 | 
			
		||||
import {
 | 
			
		||||
  atomWithLocalStorage,
 | 
			
		||||
  getLocalStorageItem,
 | 
			
		||||
  setLocalStorageItem,
 | 
			
		||||
} from './utils/atomWithLocalStorage';
 | 
			
		||||
 | 
			
		||||
const PUSH_SUBSCRIPTION_KEY = 'webPushSubscription';
 | 
			
		||||
 | 
			
		||||
const basePushSubscriptionAtom = atomWithLocalStorage<PushSubscriptionJSON | null>(
 | 
			
		||||
  PUSH_SUBSCRIPTION_KEY,
 | 
			
		||||
  (key) => getLocalStorageItem<PushSubscriptionJSON | null>(key, null),
 | 
			
		||||
  (key, value) => {
 | 
			
		||||
    setLocalStorageItem(key, value);
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const pushSubscriptionAtom = atom<
 | 
			
		||||
  PushSubscriptionJSON | null,
 | 
			
		||||
  [PushSubscription | null],
 | 
			
		||||
  void
 | 
			
		||||
>(
 | 
			
		||||
  (get) => get(basePushSubscriptionAtom),
 | 
			
		||||
  (get, set, subscription: PushSubscription | null) => {
 | 
			
		||||
    if (subscription) {
 | 
			
		||||
      const subscriptionJSON = subscription.toJSON();
 | 
			
		||||
      set(basePushSubscriptionAtom, subscriptionJSON);
 | 
			
		||||
    } else {
 | 
			
		||||
      set(basePushSubscriptionAtom, null);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			@ -32,7 +32,8 @@ export interface Settings {
 | 
			
		|||
  showHiddenEvents: boolean;
 | 
			
		||||
  legacyUsernameColor: boolean;
 | 
			
		||||
 | 
			
		||||
  showNotifications: boolean;
 | 
			
		||||
  usePushNotifications: boolean;
 | 
			
		||||
  useInAppNotifications: boolean;
 | 
			
		||||
  isNotificationSounds: boolean;
 | 
			
		||||
 | 
			
		||||
  developerTools: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -62,7 +63,8 @@ const defaultSettings: Settings = {
 | 
			
		|||
  showHiddenEvents: false,
 | 
			
		||||
  legacyUsernameColor: false,
 | 
			
		||||
 | 
			
		||||
  showNotifications: true,
 | 
			
		||||
  usePushNotifications: false,
 | 
			
		||||
  useInAppNotifications: true,
 | 
			
		||||
  isNotificationSounds: true,
 | 
			
		||||
 | 
			
		||||
  developerTools: false,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										6
									
								
								src/app/utils/appEvents.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/app/utils/appEvents.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
export const appEvents = {
 | 
			
		||||
 | 
			
		||||
  onVisibilityHidden: null as (() => void) | null,
 | 
			
		||||
 | 
			
		||||
  onVisibilityChange: null as ((isVisible: boolean) => void) | null,
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -1,39 +1,79 @@
 | 
			
		|||
/* eslint-disable import/first */
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import { createRoot } from 'react-dom/client';
 | 
			
		||||
import { enableMapSet } from 'immer';
 | 
			
		||||
import '@fontsource/inter/variable.css';
 | 
			
		||||
import 'folds/dist/style.css';
 | 
			
		||||
import { configClass, varsClass } from 'folds';
 | 
			
		||||
import './index.scss';
 | 
			
		||||
import { trimTrailingSlash } from './app/utils/common';
 | 
			
		||||
import App from './app/pages/App';
 | 
			
		||||
import './app/i18n';
 | 
			
		||||
 | 
			
		||||
enableMapSet();
 | 
			
		||||
 | 
			
		||||
import './index.scss';
 | 
			
		||||
 | 
			
		||||
import { trimTrailingSlash } from './app/utils/common';
 | 
			
		||||
import App from './app/pages/App';
 | 
			
		||||
 | 
			
		||||
// import i18n (needs to be bundled ;))
 | 
			
		||||
import './app/i18n';
 | 
			
		||||
 | 
			
		||||
document.body.classList.add(configClass, varsClass);
 | 
			
		||||
 | 
			
		||||
// Register Service Worker
 | 
			
		||||
if ('serviceWorker' in navigator) {
 | 
			
		||||
  const swUrl =
 | 
			
		||||
    import.meta.env.MODE === 'production'
 | 
			
		||||
      ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
 | 
			
		||||
      : `/dev-sw.js?dev-sw`;
 | 
			
		||||
  const isProduction = import.meta.env.MODE === 'production';
 | 
			
		||||
  const swUrl = isProduction
 | 
			
		||||
    ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
 | 
			
		||||
    : `/dev-sw.js?dev-sw`;
 | 
			
		||||
 | 
			
		||||
  const swRegisterOptions: RegistrationOptions = {};
 | 
			
		||||
  if (!isProduction) {
 | 
			
		||||
    swRegisterOptions.type = 'module';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const showUpdateAvailablePrompt = (registration: ServiceWorkerRegistration) => {
 | 
			
		||||
    const DONT_SHOW_PROMPT_KEY = 'cinny_dont_show_sw_update_prompt';
 | 
			
		||||
    const userPreference = localStorage.getItem(DONT_SHOW_PROMPT_KEY);
 | 
			
		||||
 | 
			
		||||
    if (userPreference === 'true') {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (window.confirm('A new version of the app is available. Refresh to update?')) {
 | 
			
		||||
      if (registration.waiting) {
 | 
			
		||||
        registration.waiting.postMessage({ type: 'SKIP_WAITING_AND_CLAIM' });
 | 
			
		||||
      } else {
 | 
			
		||||
        window.location.reload();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  navigator.serviceWorker.register(swUrl, swRegisterOptions).then((registration) => {
 | 
			
		||||
    registration.onupdatefound = () => {
 | 
			
		||||
      const installingWorker = registration.installing;
 | 
			
		||||
      if (installingWorker) {
 | 
			
		||||
        installingWorker.onstatechange = () => {
 | 
			
		||||
          if (installingWorker.state === 'installed') {
 | 
			
		||||
            if (navigator.serviceWorker.controller) {
 | 
			
		||||
              showUpdateAvailablePrompt(registration);
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  navigator.serviceWorker.register(swUrl);
 | 
			
		||||
  navigator.serviceWorker.addEventListener('message', (event) => {
 | 
			
		||||
    if (event.data?.type === 'token' && event.data?.responseKey) {
 | 
			
		||||
      // Get the token for SW.
 | 
			
		||||
    if (!event.data || !event.source) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (event.data.type === 'token' && event.data.id) {
 | 
			
		||||
      const token = localStorage.getItem('cinny_access_token') ?? undefined;
 | 
			
		||||
      event.source!.postMessage({
 | 
			
		||||
        responseKey: event.data.responseKey,
 | 
			
		||||
        token,
 | 
			
		||||
      event.source.postMessage({
 | 
			
		||||
        replyTo: event.data.id,
 | 
			
		||||
        payload: token,
 | 
			
		||||
      });
 | 
			
		||||
    } else if (event.data.type === 'openRoom' && event.data.id) {
 | 
			
		||||
      /* Example:
 | 
			
		||||
      event.source.postMessage({
 | 
			
		||||
        replyTo: event.data.id,
 | 
			
		||||
        payload: success?,
 | 
			
		||||
      });
 | 
			
		||||
      */
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										191
									
								
								src/sw.ts
									
										
									
									
									
								
							
							
						
						
									
										191
									
								
								src/sw.ts
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -1,19 +1,61 @@
 | 
			
		|||
/// <reference lib="WebWorker" />
 | 
			
		||||
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
 | 
			
		||||
 | 
			
		||||
export type {};
 | 
			
		||||
declare const self: ServiceWorkerGlobalScope;
 | 
			
		||||
 | 
			
		||||
async function askForAccessToken(client: Client): Promise<string | undefined> {
 | 
			
		||||
  return new Promise((resolve) => {
 | 
			
		||||
    const responseKey = Math.random().toString(36);
 | 
			
		||||
    const listener = (event: ExtendableMessageEvent) => {
 | 
			
		||||
      if (event.data.responseKey !== responseKey) return;
 | 
			
		||||
      resolve(event.data.token);
 | 
			
		||||
      self.removeEventListener('message', listener);
 | 
			
		||||
    };
 | 
			
		||||
    self.addEventListener('message', listener);
 | 
			
		||||
    client.postMessage({ responseKey, type: 'token' });
 | 
			
		||||
const DEFAULT_NOTIFICATION_ICON = '/public/res/apple/apple-touch-icon-180x180.png';
 | 
			
		||||
const DEFAULT_NOTIFICATION_BADGE = '/public/res/apple-touch-icon-72x72.png';
 | 
			
		||||
 | 
			
		||||
const pendingReplies = new Map();
 | 
			
		||||
let messageIdCounter = 0;
 | 
			
		||||
function sendAndWaitForReply(client: WindowClient, type: string, payload: object) {
 | 
			
		||||
  messageIdCounter += 1;
 | 
			
		||||
  const id = messageIdCounter;
 | 
			
		||||
  const promise = new Promise((resolve) => {
 | 
			
		||||
    pendingReplies.set(id, resolve);
 | 
			
		||||
  });
 | 
			
		||||
  client.postMessage({ type, id, payload });
 | 
			
		||||
  return promise;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function fetchWithRetry(
 | 
			
		||||
  url: string,
 | 
			
		||||
  token: string,
 | 
			
		||||
  retries = 3,
 | 
			
		||||
  delay = 250
 | 
			
		||||
): Promise<Response> {
 | 
			
		||||
  let lastError: Error | undefined;
 | 
			
		||||
 | 
			
		||||
  /*  eslint-disable no-await-in-loop */
 | 
			
		||||
  for (let attempt = 1; attempt <= retries; attempt += 1) {
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch(url, {
 | 
			
		||||
        headers: {
 | 
			
		||||
          Authorization: `Bearer ${token}`,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(`HTTP error! Status: ${response.status}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return response;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      lastError = error instanceof Error ? error : new Error(String(error));
 | 
			
		||||
 | 
			
		||||
      if (attempt < retries) {
 | 
			
		||||
        console.warn(
 | 
			
		||||
          `Fetch attempt ${attempt} failed: ${lastError.message}. Retrying in ${delay}ms...`
 | 
			
		||||
        );
 | 
			
		||||
        await new Promise((res) => {
 | 
			
		||||
          setTimeout(res, delay);
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  /*  eslint-enable no-await-in-loop */
 | 
			
		||||
  throw new Error(`Fetch failed after ${retries} retries. Last error: ${lastError?.message}`);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function fetchConfig(token?: string): RequestInit | undefined {
 | 
			
		||||
| 
						 | 
				
			
			@ -27,8 +69,39 @@ function fetchConfig(token?: string): RequestInit | undefined {
 | 
			
		|||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
self.addEventListener('message', (event: ExtendableMessageEvent) => {
 | 
			
		||||
  if (event.data.type === 'togglePush') {
 | 
			
		||||
    const token = event.data?.token;
 | 
			
		||||
    const fetchOptions = fetchConfig(token);
 | 
			
		||||
    event.waitUntil(
 | 
			
		||||
      fetch(`${event.data.url}/_matrix/client/v3/pushers/set`, {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        ...fetchOptions,
 | 
			
		||||
        body: JSON.stringify(event.data.pusherData),
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  const { replyTo } = event.data;
 | 
			
		||||
  if (replyTo) {
 | 
			
		||||
    const resolve = pendingReplies.get(replyTo);
 | 
			
		||||
    if (resolve) {
 | 
			
		||||
      pendingReplies.delete(replyTo);
 | 
			
		||||
      resolve(event.data.payload);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
self.addEventListener('activate', (event: ExtendableEvent) => {
 | 
			
		||||
  event.waitUntil(clients.claim());
 | 
			
		||||
  event.waitUntil(
 | 
			
		||||
    (async () => {
 | 
			
		||||
      await self.clients.claim();
 | 
			
		||||
    })()
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
self.addEventListener('install', (event: ExtendableEvent) => {
 | 
			
		||||
  event.waitUntil(self.skipWaiting());
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
self.addEventListener('fetch', (event: FetchEvent) => {
 | 
			
		||||
| 
						 | 
				
			
			@ -42,11 +115,99 @@ self.addEventListener('fetch', (event: FetchEvent) => {
 | 
			
		|||
  }
 | 
			
		||||
  event.respondWith(
 | 
			
		||||
    (async (): Promise<Response> => {
 | 
			
		||||
      if (!event.clientId) throw new Error('Missing clientId');
 | 
			
		||||
      const client = await self.clients.get(event.clientId);
 | 
			
		||||
      let token: string | undefined;
 | 
			
		||||
      if (client) token = await askForAccessToken(client);
 | 
			
		||||
 | 
			
		||||
      return fetch(url, fetchConfig(token));
 | 
			
		||||
      if (!client) throw new Error('Client not found');
 | 
			
		||||
      const token = await sendAndWaitForReply(client, 'token', {});
 | 
			
		||||
      if (!token) throw new Error('Failed to retrieve token');
 | 
			
		||||
      const response = await fetchWithRetry(url, token);
 | 
			
		||||
      return response;
 | 
			
		||||
    })()
 | 
			
		||||
  );
 | 
			
		||||
  event.waitUntil(
 | 
			
		||||
    (async function () {
 | 
			
		||||
      console.log('Ensuring fetch processing completes before worker termination.');
 | 
			
		||||
    })()
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const onPushNotification = async (event: PushEvent) => {
 | 
			
		||||
  let title = 'New Notification';
 | 
			
		||||
  const options: NotificationOptions = {
 | 
			
		||||
    body: 'You have a new message!',
 | 
			
		||||
    icon: DEFAULT_NOTIFICATION_ICON,
 | 
			
		||||
    badge: DEFAULT_NOTIFICATION_BADGE,
 | 
			
		||||
    data: {
 | 
			
		||||
      url: self.registration.scope,
 | 
			
		||||
      timestamp: Date.now(),
 | 
			
		||||
    },
 | 
			
		||||
    // tag: 'cinny-notification-tag', // Optional: Replaces existing notification with same tag
 | 
			
		||||
    // renotify: true, // Optional: If using tag, renotify will alert user even if tag matches
 | 
			
		||||
    // silent: false, // Optional: Set to true for no sound/vibration. User can also set this.
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (event.data) {
 | 
			
		||||
    try {
 | 
			
		||||
      const pushData = event.data.json();
 | 
			
		||||
      title = pushData.title || title;
 | 
			
		||||
      options.body = options.body ?? pushData.data.toString();
 | 
			
		||||
      options.icon = pushData.icon || options.icon;
 | 
			
		||||
      options.badge = pushData.badge || options.badge;
 | 
			
		||||
 | 
			
		||||
      if (pushData.image) options.image = pushData.image;
 | 
			
		||||
      if (pushData.vibrate) options.vibrate = pushData.vibrate;
 | 
			
		||||
      if (pushData.actions) options.actions = pushData.actions;
 | 
			
		||||
      options.tag = 'Cinny';
 | 
			
		||||
      if (typeof pushData.renotify === 'boolean') options.renotify = pushData.renotify;
 | 
			
		||||
      if (typeof pushData.silent === 'boolean') options.silent = pushData.silent;
 | 
			
		||||
 | 
			
		||||
      if (pushData.data) {
 | 
			
		||||
        options.data = { ...options.data, ...pushData.data };
 | 
			
		||||
      }
 | 
			
		||||
      if (typeof pushData.unread === 'number') {
 | 
			
		||||
        try {
 | 
			
		||||
          self.navigator.setAppBadge(pushData.unread);
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          // Likely Firefox/Gecko-based and doesn't support badging API
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        await navigator.clearAppBadge();
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      const pushText = event.data.text();
 | 
			
		||||
      options.body = pushText || options.body;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return self.registration.showNotification(title, options);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
self.addEventListener('push', (event: PushEvent) => event.waitUntil(onPushNotification(event)));
 | 
			
		||||
 | 
			
		||||
self.addEventListener('notificationclick', (event: NotificationEvent) => {
 | 
			
		||||
  event.notification.close();
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * We should likely add a postMessage back to navigate to the room the event is from
 | 
			
		||||
   */
 | 
			
		||||
  const targetUrl = event.notification.data?.url || self.registration.scope;
 | 
			
		||||
 | 
			
		||||
  event.waitUntil(
 | 
			
		||||
    self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
 | 
			
		||||
      for (const client of clientList) {
 | 
			
		||||
        if (client.url === targetUrl && 'focus' in client) {
 | 
			
		||||
          return (client as WindowClient).focus();
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      if (self.clients.openWindow) {
 | 
			
		||||
        return self.clients.openWindow(targetUrl);
 | 
			
		||||
      }
 | 
			
		||||
      return Promise.resolve();
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
if (self.__WB_MANIFEST) {
 | 
			
		||||
  precacheAndRoute(self.__WB_MANIFEST);
 | 
			
		||||
}
 | 
			
		||||
cleanupOutdatedCaches();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue