adding browser push notifiication support

This commit is contained in:
Roland Osborne 2024-06-05 18:41:42 -07:00
parent 054284a6ed
commit 26ef4b6800
22 changed files with 335 additions and 20 deletions

View File

@ -696,6 +696,36 @@ paths:
required: false
schema:
type: string
- name: deviceToken
in: query
description: deviceToken for push notification
required: false
schema:
type: string
- name: webEndpoint
in: query
description: webpush endpoint
required: false
schema:
type: string
- name: webPublicKey
in: query
description: webpush public key
required: false
schema:
type: string
- name: webAuth
in: query
description: webpush authorization
required: false
schema:
type: string
- name: pushType
in: query
description: unifiedpush (up) or firebase (fcm), or webpush (web)
required: false
schema:
type: string
responses:
'201':
description: success
@ -4164,6 +4194,7 @@ components:
- searchable
- pushEnabled
- multiFactorAuth
- webServerKey
properties:
disabled:
type: boolean
@ -4189,6 +4220,8 @@ components:
type: boolean
multiFactorAuth:
type: boolean
webPushKey:
type: string
AccountProfile:
type: object

View File

@ -11,15 +11,17 @@ require (
github.com/stretchr/testify v1.7.0
github.com/theckman/go-securerandom v0.1.1
github.com/valyala/fastjson v1.6.4
golang.org/x/crypto v0.21.0
golang.org/x/crypto v0.24.0
gorm.io/driver/sqlite v1.5.5
gorm.io/gorm v1.25.9
)
require (
github.com/SherClockHolmes/webpush-go v1.3.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/text v0.2.0 // indirect

View File

@ -1,3 +1,5 @@
github.com/SherClockHolmes/webpush-go v1.3.0 h1:CAu3FvEE9QS4drc3iKNgpBWFfGqNthKlZhp5QpYnu6k=
github.com/SherClockHolmes/webpush-go v1.3.0/go.mod h1:AxRHmJuYwKGG1PVgYzToik1lphQvDnqFYDqimHvwhIw=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@ -7,6 +9,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
@ -43,12 +47,16 @@ github.com/theckman/go-securerandom v0.1.1/go.mod h1:bmkysLfBH6i891sBpcP4xRM3XIB
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=

View File

@ -102,7 +102,7 @@ func AddAccountApp(w http.ResponseWriter, r *http.Request) {
session.Platform = platform
session.PushToken = deviceToken
session.PushType = pushType
session.PushEnabled = true
session.PushEnabled = pushType != ""
if res := tx.Save(session).Error; res != nil {
return res
}

View File

@ -39,6 +39,7 @@ func GetAccountStatus(w http.ResponseWriter, r *http.Request) {
status.Sealable = true
status.EnableIce = getBoolConfigValue(CNFEnableIce, false)
status.AllowUnsealed = getBoolConfigValue(CNFAllowUnsealed, false)
status.WebPushKey = getStrConfigValue(CNFWebPublicKey, "");
status.PushEnabled = session.PushEnabled
status.Seal = seal
WriteResponse(w, status)

View File

@ -15,6 +15,12 @@ func SetAccountNotification(w http.ResponseWriter, r *http.Request) {
return
}
deviceToken := r.FormValue("deviceToken")
webEndpoint := r.FormValue("webEndpoint")
webPublicKey := r.FormValue("webPublicKey")
webAuth := r.FormValue("webAuth");
pushType := r.FormValue("pushType");
var flag bool
if err := ParseRequest(r, w, &flag); err != nil {
ErrResponse(w, http.StatusBadRequest, err)
@ -25,6 +31,33 @@ func SetAccountNotification(w http.ResponseWriter, r *http.Request) {
if res := tx.Model(session).Update("push_enabled", flag).Error; res != nil {
return res
}
if deviceToken != "" {
if res := tx.Model(session).Update("push_token", deviceToken).Error; res != nil {
return res
}
}
if webEndpoint != "" {
if res := tx.Model(session).Update("web_endpoint", webEndpoint).Error; res != nil {
return res
}
}
if webPublicKey != "" {
if res := tx.Model(session).Update("web_public_key", webPublicKey).Error; res != nil {
return res
}
}
if webAuth != "" {
if res := tx.Model(session).Update("web_auth", webAuth).Error; res != nil {
return res
}
}
if pushType != "" {
if res := tx.Model(session).Update("push_type", pushType).Error; res != nil {
return res
}
}
session.Account.AccountRevision += 1;
if res := tx.Model(session.Account).Update("account_revision", session.Account.AccountRevision).Error; res != nil {
return res

View File

@ -1,6 +1,7 @@
package databag
import (
webpush "github.com/SherClockHolmes/webpush-go"
"databag/internal/store"
"net/http"
"bytes"
@ -47,7 +48,7 @@ func SendPushEvent(account store.Account, event string) {
}
// get all sessions supporting push for specified event
rows, err := store.DB.Table("sessions").Select("sessions.push_token, sessions.push_type, push_events.message_title, push_events.message_body").Joins("left join push_events on push_events.session_id = sessions.id").Where("sessions.account_id = ? AND sessions.push_enabled = ? AND push_events.event = ?", account.GUID, true, event).Rows();
rows, err := store.DB.Table("sessions").Select("sessions.push_token, sessions.push_type, sessions.web_auth, sessions.web_public_key, sessions.web_endpoint, push_events.message_title, push_events.message_body").Joins("left join push_events on push_events.session_id = sessions.id").Where("sessions.account_id = ? AND sessions.push_enabled = ? AND push_events.event = ?", account.GUID, true, event).Rows();
if err != nil {
ErrMsg(err);
return
@ -59,16 +60,20 @@ func SendPushEvent(account store.Account, event string) {
var pushType string
var messageTitle string
var messageBody string
var webAuth string
var webPublicKey string
var webEndpoint string
rows.Scan(&pushToken, &pushType, &messageTitle, &messageBody)
if pushToken == "" || pushToken == "null" {
continue;
}
rows.Scan(&pushToken, &pushType, &webAuth, &webPublicKey, &webEndpoint, &messageTitle, &messageBody)
pushRef := pushType + ":" + pushToken + ":" + webAuth;
if _, exists := tokens[pushToken]; !exists {
tokens[pushToken] = true;
if _, exists := tokens[pushRef]; !exists {
tokens[pushRef] = true;
if pushType == "up" {
if pushToken == "" || pushToken == "null" {
continue;
}
message := []byte(messageTitle);
req, err := http.NewRequest(http.MethodPost, pushToken, bytes.NewBuffer(message))
if err != nil {
@ -84,7 +89,38 @@ func SendPushEvent(account store.Account, event string) {
if resp.StatusCode != 200 {
ErrMsg(errors.New("failed to push notification"));
}
} else if pushType == "web" {
if webEndpoint == "" || webEndpoint == "null" {
continue;
}
keys := webpush.Keys{
Auth: webAuth,
P256dh: webPublicKey,
}
subscription := &webpush.Subscription{
Endpoint: webEndpoint,
Keys: keys,
}
msg := []byte("{ \"title\": \"Databag\", \"message\": \"" + messageTitle + "\" }")
options := &webpush.Options{
RecordSize: 0,
Topic: "Databag",
Subscriber: account.Handle,
Urgency: webpush.UrgencyHigh,
VAPIDPublicKey: getStrConfigValue(CNFWebPublicKey, ""),
VAPIDPrivateKey: getStrConfigValue(CNFWebPrivateKey, ""),
TTL: 30,
}
resp, err := webpush.SendNotification(msg, subscription, options);
defer resp.Body.Close()
if err != nil {
ErrMsg(err)
continue
}
} else {
if pushToken == "" || pushToken == "null" {
continue;
}
url := "https://fcm.googleapis.com/fcm/send"
payload := Payload{ Title: messageTitle, Body: messageBody, Sound: "default" };
message := Message{ Notification: payload, To: pushToken };

View File

@ -84,6 +84,12 @@ const CNFMFASecret = "mfa_secret"
//CNFAdminSession sepcifies the admin session token
const CNFAdminSession = "admin_session"
//CNFWebPrivateKey specifies private key for webpush notifications
const CNFWebPrivateKey = "web_private_key"
//CNFWebPublicKey specifies public key for webpush notifications
const CNFWebPublicKey = "web_public_key"
func getStrConfigValue(configID string, empty string) string {
var config store.Config
err := store.DB.Where("config_id = ?", configID).First(&config).Error

View File

@ -46,6 +46,8 @@ type AccountStatus struct {
EnableIce bool `json:"enableIce"`
AllowUnsealed bool `json:"allowUnsealed"`
WebPushKey string `json:"webPushKey"`
}
//Announce initial message sent on websocket

View File

@ -116,6 +116,9 @@ type Session struct {
PushEnabled bool
PushToken string
PushType string
WebEndpoint string
WebPublicKey string
WebAuth string
Created int64 `gorm:"autoCreateTime"`
Account Account `gorm:"references:GUID"`
Token string `gorm:"not null;index:sessguid,unique"`

View File

@ -4,6 +4,10 @@ import (
app "databag/internal"
"databag/internal/store"
"github.com/gorilla/handlers"
webpush "github.com/SherClockHolmes/webpush-go"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"errors"
"log"
"net/http"
"os"
@ -36,6 +40,42 @@ func main() {
}
store.SetPath(storePath, transformPath);
// setup vapid keys
var config store.Config
err := store.DB.Where("config_id = ?", app.CNFWebPrivateKey).First(&config).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
if err != nil {
log.Fatal(err)
} else {
err = store.DB.Transaction(func(tx *gorm.DB) error {
if res := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "config_id"}},
DoUpdates: clause.AssignmentColumns([]string{"str_value"}),
}).Create(&store.Config{ConfigID: app.CNFWebPublicKey, StrValue: publicKey}).Error; res != nil {
return res
}
return nil
})
if err != nil {
log.Fatal(err);
}
err = store.DB.Transaction(func(tx *gorm.DB) error {
if res := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "config_id"}},
DoUpdates: clause.AssignmentColumns([]string{"str_value"}),
}).Create(&store.Config{ConfigID: app.CNFWebPrivateKey, StrValue: privateKey}).Error; res != nil {
return res
}
return nil
})
if err != nil {
log.Fatal(err);
}
}
}
router := app.NewRouter(webApp)
origins := handlers.AllowedOrigins([]string{"*"})
methods := handlers.AllowedMethods([]string{"GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"})

32
net/web/public/push.js Normal file
View File

@ -0,0 +1,32 @@
self.addEventListener('push', function(event) {
if (!(self.Notification && self.Notification.permission === 'granted')) {
return;
}
var data = {};
if (event.data) {
data = event.data.json();
}
var title = data.title;
var message = data.message;
var icon = "favicon.ico";
self.clickTarget = self.location.origin;
event.waitUntil(self.registration.showNotification(title, {
body: message,
tag: 'Databag',
icon: icon,
}));
});
self.addEventListener('notificationclick', function(event) {
console.log('[Service Worker] Notification click Received.');
event.notification.close();
if(clients.openWindow){
event.waitUntil(clients.openWindow(self.clickTarget));
}
});

View File

@ -1,7 +1,7 @@
import { checkResponse, fetchWithTimeout } from './fetchUtil';
export async function setAccountAccess(token, appName, appVersion, platform) {
let access = await fetchWithTimeout(`/account/access?token=${token}&appName=${appName}&appVersion=${appVersion}&platform=${platform}`, { method: 'PUT', body: JSON.stringify([]) })
export async function setAccountAccess(token, appName, appVersion, platform, notifications) {
let access = await fetchWithTimeout(`/account/access?token=${token}&appName=${appName}&appVersion=${appVersion}&platform=${platform}`, { method: 'PUT', body: JSON.stringify(notifications) })
checkResponse(access)
return await access.json()
}

View File

@ -0,0 +1,10 @@
import { checkResponse, fetchWithTimeout } from './fetchUtil';
export async function setAccountNotifications(token, webEndpoint, webPublicKey, webAuth, flag) {
const endpointEnc = encodeURIComponent(webEndpoint);
const publicKeyEnc = encodeURIComponent(webPublicKey);
const authEnc = encodeURIComponent(webAuth);
let res = await fetchWithTimeout(`/account/notification?agent=${token}&webEndpoint=${endpointEnc}&webPublicKey=${publicKeyEnc}&webAuth=${authEnc}&pushType=web`, { method: 'PUT', body: JSON.stringify(flag) })
checkResponse(res);
}

View File

@ -1,12 +1,12 @@
import { checkResponse, fetchWithTimeout } from './fetchUtil';
var base64 = require('base-64');
export async function setLogin(username, password, code, appName, appVersion, userAgent) {
export async function setLogin(username, password, code, appName, appVersion, userAgent, notifications) {
const platform = encodeURIComponent(userAgent);
const mfa = code ? `&code=${code}` : '';
let headers = new Headers()
headers.append('Authorization', 'Basic ' + base64.encode(username + ":" + password));
let login = await fetchWithTimeout(`/account/apps?appName=${appName}&appVersion=${appVersion}&platform=${platform}${mfa}`, { method: 'POST', body: JSON.stringify([]), headers: headers })
let login = await fetchWithTimeout(`/account/apps?appName=${appName}&appVersion=${appVersion}&platform=${platform}${mfa}`, { method: 'POST', body: JSON.stringify(notifications), headers: headers })
checkResponse(login)
return await login.json()
}

View File

@ -9,6 +9,7 @@ export const en = {
allDevices: 'Logout of all devices',
ok: 'OK',
cancel: 'Cancel',
enableNotifications: 'Push Notifications',
new: 'New',
newMessage: 'New Message',
@ -217,6 +218,7 @@ export const fr = {
allDevices: 'Déconnexion de tous les appareils',
ok: 'OK',
cancel: 'Annuler',
enableNotifications: 'Notifications Push',
new: 'Nouveau',
newMessage: 'Nouveau Message',
@ -426,6 +428,7 @@ export const sp = {
allDevices: 'Cerrar sesión en todos los dispositivos',
ok: 'Aceptar',
cancel: 'Cancelar',
enableNotifications: 'Notificaciones Push',
new: 'Nuevo',
newMessage: 'Nuevo mensaje',
@ -634,6 +637,7 @@ export const pt = {
allDevices: 'Desconectar de todos os dispositivos',
ok: 'OK',
cancel: 'Cancelar',
enableNotifications: 'Notificações Push',
new: 'Novo',
newMessage: 'Nova mensagem',
@ -842,6 +846,7 @@ export const de = {
allDevices: 'Alle Geräte abmelden',
ok: 'OK',
cancel: 'Abbrechen',
enableNotifications: 'Mitteilungen',
new: 'Neu',
newMessage: 'Neue Nachricht',
@ -1050,6 +1055,7 @@ export const ru = {
allDevices: 'Выйти со всех устройств',
ok: 'OK',
cancel: 'Отмена',
enableNotifications: 'Уведомления',
new: 'Новый',
newMessage: 'Новое сообщение',

View File

@ -1,5 +1,6 @@
import { useEffect, useContext, useState, useRef } from 'react';
import { setAccountSearchable } from 'api/setAccountSearchable';
import { setAccountNotifications } from 'api/setAccountNotifications';
import { setAccountSeal } from 'api/setAccountSeal';
import { getAccountStatus } from 'api/getAccountStatus';
import { setAccountLogin } from 'api/setAccountLogin';
@ -8,12 +9,28 @@ import { setAccountMFA } from 'api/setAccountMFA';
import { removeAccountMFA } from 'api/removeAccountMFA';
import { StoreContext } from './StoreContext';
function urlB64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (var i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export function useAccountContext() {
const [state, setState] = useState({
offsync: false,
status: null,
seal: null,
sealKey: null,
webPushKey: null,
});
const access = useRef(null);
const setRevision = useRef(null);
@ -40,8 +57,10 @@ export function useAccountContext() {
const token = access.current;
const revision = curRevision.current;
const status = await getAccountStatus(token);
const { seal, webPushKey } = status || {};
setRevision.current = revision;
updateState({ offsync: false, status, seal: status.seal });
updateState({ offsync: false, status, seal, webPushKey });
}
catch (err) {
console.log(err);
@ -75,6 +94,39 @@ export function useAccountContext() {
setSearchable: async (flag) => {
await setAccountSearchable(access.current, flag);
},
setPushEnabled: async (flag) => {
if (flag) {
const status = await Notification.requestPermission();
if (status == 'granted') {
const registration = await navigator.serviceWorker.register('push.js');
await navigator.serviceWorker.ready;
const params = { userVisibleOnly: true, applicationServerKey: urlB64ToUint8Array(state.webPushKey) };
const subscription = await registration.pushManager.subscribe(params);
const endpoint = subscription.endpoint;
const binPublicKey = subscription.getKey('p256dh');
const binAuth = subscription.getKey('auth');
if (endpoint && binPublicKey && binAuth) {
const numPublicKey = [];
(new Uint8Array(binPublicKey)).forEach(val => {
numPublicKey.push(val);
});
const numAuth = [];
(new Uint8Array(binAuth)).forEach(val => {
numAuth.push(val);
});
const publicKey = btoa(String.fromCharCode.apply(null, numPublicKey));
const auth = btoa(String.fromCharCode.apply(null, numAuth));
await setAccountNotifications(access.current, endpoint, publicKey, auth, true);
}
}
}
else {
await setAccountNotifications(access.current, '', '', '', false);
}
},
enableMFA: async () => {
const secret = await addAccountMFA(access.current);
return secret;

View File

@ -70,6 +70,16 @@ export function useAppContext(websocket) {
clearWebsocket();
}
const notifications = [
{ event: 'contact.addCard', messageTitle: 'New Contact Request' },
{ event: 'contact.updateCard', messageTitle: 'Contact Update' },
{ event: 'content.addChannel.superbasic', messageTitle: 'New Topic' },
{ event: 'content.addChannel.sealed', messageTitle: 'New Topic' },
{ event: 'content.addChannelTopic.superbasic', messageTitle: 'New Topic Message' },
{ event: 'content.addChannelTopic.sealed', messageTitle: 'New Topic Message' },
{ event: 'ring', messageTitle: 'Incoming Call' },
];
const actions = {
logout: async (all) => {
await appLogout(all);
@ -96,7 +106,7 @@ export function useAppContext(websocket) {
throw new Error('invalid session state');
}
await addAccount(username, password, token);
const access = await setLogin(username, password, null, appName, appVersion, userAgent);
const access = await setLogin(username, password, null, appName, appVersion, userAgent, notifications);
storeContext.actions.setValue('login:timestamp', access.created);
setSession(access.appToken);
appToken.current = access.appToken;
@ -112,7 +122,7 @@ export function useAppContext(websocket) {
if (appToken.current || !checked.current) {
throw new Error('invalid session state');
}
const access = await setLogin(username, password, code, appName, appVersion, userAgent);
const access = await setLogin(username, password, code, appName, appVersion, userAgent, notifications);
storeContext.actions.setValue('login:timestamp', access.created);
setSession(access.appToken);
appToken.current = access.appToken;
@ -128,7 +138,7 @@ export function useAppContext(websocket) {
if (appToken.current || !checked.current) {
throw new Error('invalid session state');
}
const access = await setAccountAccess(token, appName, appVersion, userAgent);
const access = await setAccountAccess(token, appName, appVersion, userAgent, notifications);
storeContext.actions.setValue('login:timestamp', access.created);
setSession(access.appToken);
appToken.current = access.appToken;

View File

@ -40,6 +40,20 @@ export function AccountAccess() {
}
};
const savePushEnabled = async (enable) => {
try {
await actions.setPushEnabled(enable);
}
catch (err) {
console.log(err);
modal.error({
title: <span style={state.menuStyle}>{state.strings.operationFailed}</span>,
content: <span style={state.menuStyle}>{state.strings.tryAgain}</span>,
bodyStyle: { borderRadius: 8, padding: 16, ...state.menuStyle },
});
}
};
const enableMFA = async (enable) => {
try {
if (enable) {
@ -104,6 +118,12 @@ export function AccountAccess() {
</div>
<div className="switchLabel">{state.strings.registry}</div>
</div>
<div className="switch">
<div className="control">
<Switch size="small" checked={state.pushEnabled} onChange={enable => savePushEnabled(enable)} />
</div>
<div className="switchLabel">{state.strings.enableNotifications}</div>
</div>
<div className="switch">
<div className="control">
<Switch size="small" checked={state.mfaEnabled} onChange={enable => enableMFA(enable)} />

View File

@ -19,6 +19,8 @@ export function useAccountAccess() {
searchable: null,
checked: true,
pushEnabled: false,
webPushKey: null,
sealEnabled: false,
sealMode: null,
sealPassword: null,
@ -67,8 +69,9 @@ export function useAccountAccess() {
}, [profile.state]);
useEffect(() => {
const { seal, sealKey, status } = account.state;
updateState({ searchable: status?.searchable, mfaEnabled: status?.mfaEnabled, seal, sealKey });
const { seal, sealKey, status, webPushKey } = account.state;
const { searchable, mfaEnabled, pushEnabled } = status || {};
updateState({ searchable, mfaEnabled, seal, sealKey, webPushKey, pushEnabled });
}, [account.state]);
useEffect(() => {
@ -315,6 +318,20 @@ export function useAccountAccess() {
}
}
},
setPushEnabled: async (flag) => {
if (!state.busy) {
try {
updateState({ busy: true });
await account.actions.setPushEnabled(flag);
updateState({ busy: false });
}
catch (err) {
console.log(err);
updateState({ busy: false });
throw new Error('failed to set searchable');
}
}
},
setCode: async (code) => {
updateState({ mfaCode: code });
},

View File

@ -126,7 +126,7 @@ export function AddTopic({ contentKey }) {
</div>
)}
<div className="message">
<Input.TextArea ref={msg} placeholder={state.strings.newMessage} spellCheck="true" autoSize={{ minRows: 2, maxRows: 6 }}
<Input.TextArea className="messageInput" ref={msg} placeholder={state.strings.newMessage} spellCheck="true" autoSize={{ minRows: 2, maxRows: 6 }}
enterkeyhint="send" onKeyDown={(e) => keyDown(e)} onChange={(e) => actions.setMessageText(e.target.value)}
value={state.messageText} autocapitalize="none" />
</div>

View File

@ -26,6 +26,10 @@ export const AddTopicWrapper = styled.div`
padding-bottom: 8px;
}
.messageInput {
background-color: ${props => props.theme.inputArea};
}
.assets {
margin-top: 8px;
height: 128px;