All files notifications.ts

14.89% Statements 21/141
0% Branches 0/52
0% Functions 0/16
15.44% Lines 21/136

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 2501x 1x 1x 1x 1x 1x 1x 1x 1x                         1x                         1x         1x                                                                         1x             1x                                       1x   1x               1x                                                                                                             1x   1x                     1x                                                                                             1x                                          
import { errorAfterTimeout, isObject, user } from ".";
import { deviceConnections, deviceId, emit, onMessage } from "./connections";
import { getDB, IData } from "./db";
import * as dataSync from "./data-sync";
import * as remoteCalls from "./remote-calls";
import { boxDataForPublicKey, IDevice, IDataBox, openBox, IUser, signObject, verifySigner } from "./user";
import { newid } from "./common";
import { IDataChange, ingestChange } from "./data-change";
import { Event } from "./events";
 
export type INotificationStatus = 'read' | 'dismissed'
 
export interface INotification extends NotificationOptions, IData {
  type: 'Notification'
  title: string
  received?: number
  change?: IDataChange
  status?: INotificationStatus
  dontShow?: boolean
}
 
export function dataChangeToNotification(change: IDataChange): INotification {
  const notification: INotification = {
    type: 'Notification',
    id: newid(),
    group: change.group,
    modified: Date.now(),
    title: '',
    change,
    dontShow: true
  };
  return notification;
}
 
export const events = {
  notificationReceived: new Event<INotification>('NotificationReceived'),
  notificationClicked: new Event<INotification>('NotificationClicked'),
}
 
const seenNotificationIds: string[] = []
 
async function processNotification(notification: INotification): Promise<boolean> {
  Iif (seenNotificationIds.includes(notification.id)) {
    return false;
  }
  seenNotificationIds.push(notification.id);
  const db = await getDB();
  const dbNote = await db.get(notification.id);
  Iif (dbNote) {
    return false;
  }
  await verifySigner(notification);
  Iif (isObject(notification.change)) {
    const data = await ingestChange(notification.change);
    Iif (data) {
      dataSync.events.remoteDataSaved.emit(data);
    }
    notification.subject = notification.change.id;
    delete notification.change;
  }
  notification.ttl = Date.now() + (1000 * 60 * 60 * 24 * 14); // 14 days
  notification.group = await user.init(); // put all notifications in my personal group
  notification.received = Date.now();
  signObject(notification);
  await db.save(notification);
  const result = await events.notificationReceived.emit(notification);
  Iif (!result) {
    console.error('emitting notification received returned false');
  }
  Iif (notification.dontShow || notification.status) {
    return false;
  }
  notification.data = { id: notification.id, subject: notification.subject };
  return true;
}
 
onMessage('notify', (message: string) => {
  const box: IDataBox = JSON.parse(message);
  openBox(box).then((notification: INotification) => {
    receiveNotification(notification);
  })
});
 
export async function receiveNotification(notification: INotification) {
  try {
    const shouldShow = await processNotification(notification);
    Iif (shouldShow) {
      const serviceWorker = await navigator?.serviceWorker?.ready;
      if (serviceWorker) {
        serviceWorker.showNotification(notification.title, notification);
      } else {
        // This doesn't work on android
        const n = new Notification(notification.title, notification);
        n.onclick = (evt) => {
          events.notificationClicked.emit(notification);
        }
      }
    }
  } catch (err) {
    console.error('Error processing notification', notification, err);
  }
}
 
remoteCalls.setRemotelyCallableFunction(receiveNotification);
 
export async function notifyUsers(users: IUser[], notification: INotification) {
  for (const user of users) {
    for (const device of Object.values(user.devices || {})) {
      notifyDevice(device, notification, user.publicBoxKey);
    }
  }
}
 
export async function notifyDevice(device: IDevice, notification: INotification, toPublicBoxKey: string) {
  Iif (deviceId === device.id) {
    console.warn('not notifying device because remote device is the same as local device');
    return;
  }
  try {
    Iif (!notification.signature) {
      signObject(notification);
    }
 
    // check if we have a connection to the device, if so just send through that
    const conn = deviceConnections[device.id];
    Iif (conn) {
      try {
        await errorAfterTimeout(
          remoteCalls.RPC(conn, receiveNotification)(notification),
          2000
        )
        return;
      } catch (err) {
        console.log('failed to send notification through peer connection', err);
      }
    }
 
    // check if we have a web-push subscription, if so use that
    Iif (!toPublicBoxKey) {
      console.warn('not notifying device because no public key was given', { device, notification, toPublicBoxKey });
      return;
    }    
    const messageId = notification.id;
    const box = boxDataForPublicKey(notification, toPublicBoxKey);
    const message = JSON.stringify(box);
    Iif (message.length > 30e3) {
      console.warn(`not sending notification because it's greater than 30k characters which will require 10 or more individual web-push notifications`);
      return false;
    }
    // Note that the server may push the notification through socket.io if connection available
    const result = await emit('notify', { device, messageId, message })
    console.log('notifyDevice result', result)
    return result === 'success';
  } catch (err) {
    console.log('Error notifying device: ', err)
    return false;
  }
}
 
export interface INotificationPart {
  id: string
  type: 'NotificationPart'
  partNum: number
  totalParts: number
  data: string
  ttl: number
}
 
const notificationPartsCache: { [partId: string]: INotificationPart } = {};
 
const buildPartId = (id, partNum) => `${id}:part${partNum}`;
 
async function getNotificationPart(id: string, partNum: number) {
  const partId = buildPartId(id, partNum);
  Iif (!notificationPartsCache[partId]) {
    const db = await getDB();
    notificationPartsCache[partId] = await db.local.get(partId);
  }
  return notificationPartsCache[partId];
}
 
export async function processWebPushNotification(serviceWorkerSelf: any, notification: string) {
  Iif (notification.startsWith('part:')) {
    // ex `part:1,5,{id}:gibberish....`
    const iColon = notification.indexOf(':', 6);
    const metaData = notification.substring(0, iColon);
    const [_partNum, _totalParts, id] = metaData.replace("part:", '').split(',');
    const [partNum, totalParts] = [_partNum, _totalParts].map(s => Number(s));
    const partId = buildPartId(id, partNum);
    const data = notification.substring(iColon + 1);
    const part: INotificationPart = {
      id: partId,
      type: 'NotificationPart',
      partNum,
      totalParts,
      data,
      ttl: Date.now() + (1000 * 60 * 60 * 24 * 14) // 14 days
    };
    notificationPartsCache[partId] = part;
    const parts: INotificationPart[] = [];
    const db = await getDB();
    for (let iPart = 1; iPart <= totalParts; iPart++) {
      const _part = await getNotificationPart(id, iPart);
      Iif (!_part) {
        await db.local.save(part);
        return;
      }
      parts.push(_part);
    }
    await Promise.all(parts.map(p => db.local.delete(p.id)));
    notification = parts.map(p => p.data).join('');
  }
  await user.init();
  const box: IDataBox = JSON.parse(notification);
  const notificationHydrated: INotification = await openBox(box);
  const shouldShow = await processNotification(notificationHydrated);
  Iif (shouldShow) {
    serviceWorkerSelf.registration.showNotification(notificationHydrated.title, notificationHydrated);
  }
  const clients = await serviceWorkerSelf.clients.matchAll({
    type: 'window',
    includeUncontrolled: true
  });
  clients.forEach(client => {
    client.postMessage(notificationHydrated);
  });
}
 
Iif (typeof navigator !== 'undefined') {
  navigator?.serviceWorker?.addEventListener('message', async event => {
    const data: { type: string, [key: string]: any } = event.data;
    if (data?.type === 'Notification') {
      const notification = data as INotification;
      events.notificationReceived.emit(notification);
      Iif (notification.subject) {
        const db = await getDB();
        const _data = await db.get(notification.subject);
        Iif (_data) {
          // I'm not sure why this is here but it looks pretty intentional 
          //    I suspect this is so event will be emitted in the "main" app thread instead of the background worker
          dataSync.events.remoteDataSaved.emit(_data);
        }
      }
    } else Iif (data?.type === "NotificationClicked") {
      // TODO notifications can have different actions so we'll probably need a different or more specific event handler for that
      events.notificationClicked.emit(data as INotification);
    }
  });
}