diff --git a/doc/CHANNELING-API.txt b/doc/CHANNELING-API.txt index b4624662..65d28cc2 100644 --- a/doc/CHANNELING-API.txt +++ b/doc/CHANNELING-API.txt @@ -52,11 +52,14 @@ Sending vs receiving document data encapsulation { "Type": "Whatever", - "Whatever": { /* your document */ } + "Whatever": { /* your document */ }, + "Iid": "request-identifier-unique-to-client" } So any document you sent, you have to specify a Type key pointing - to the key where the real document is to be found. + to the key where the real document is to be found. The Iid field is + optional and is returned back with the response wrapper document to + match requests with response data when supported by the type. Received documents are wrapped by a special Document which provides additional information. @@ -64,7 +67,8 @@ Sending vs receiving document data encapsulation { "From": "4", "To": "5", - "Data": {} + "Data": {}, + "Iid": "request-identifier-unique-to-client" } The Data key contains the real Document. @@ -76,6 +80,9 @@ Sending vs receiving document data encapsulation To : The Id, the server send this Document to. Should be the same as your current Self Id. Data : Contains the payload. + Iid : Optional request identifier to match this response to the calling + request. Only available when sent by the client and the requested + type implementation does support it. Special purpose documents for channling @@ -87,6 +94,7 @@ Special purpose documents for channling "Id": "4", "Sid": "5157", "Userid": "", + "Suserid": "", "Token": "some-very-long-string", "Version": "server-version-number", "Turn": { @@ -108,20 +116,21 @@ Special purpose documents for channling Keys: - Type : Self (string) - Id : Public Session id for this connection (string). - Sid : Secure (non public) id for this session (string). - Userid : User id if this session belongs to an authenticated user. Else empty. - Token : Security token (string), to restablish connection with the same + Type : Self (string) + Id : Public Session id for this connection (string). + Sid : Secure (non public) id for this session (string). + Userid : User id if this session belongs to an authenticated user. Else empty. + Suserid : Secure (non public) user id if session has an user id. Else empty. + Token : Security token (string), to restablish connection with the same session. Pass the value as URL query parameter t, to the websocket URL. - Version: Server version number. Use this to detect server upgrades. - Turn : Mapping (interface{}) to contain TURN server details, like - urls, password and username. See - http://tools.ietf.org/html/draft-uberti-behave-turn-rest-00 - and TURN REST API section in - https://code.google.com/p/rfc5766-turn-server/wiki/turnserver - for details. - Stun : Array with STUN server URLs. + Version : Server version number. Use this to detect server upgrades. + Turn : Mapping (interface{}) to contain TURN server details, like + urls, password and username. See + http://tools.ietf.org/html/draft-uberti-behave-turn-rest-00 + and TURN REST API section in + https://code.google.com/p/rfc5766-turn-server/wiki/turnserver + for details. + Stun : Array with STUN server URLs. You can also send an empty Self document to the server to make the server transmit a fresh Self document (eg. to refresh when ttl was reached). Please @@ -273,7 +282,8 @@ Additional types for session listing and notifications "Id": "7", "Userid": "u7", "Ua": "Chrome 28", - "Status": null + "Status": null, + "Prio": 100 } Note: The Userid field is only present if that session belongs to a known user. @@ -303,11 +313,7 @@ Additional types for session listing and notifications Users (Request uses empty data) { - "Type": "Users", - "Users": { - "Type": "Users", - "Users": {} - } + "Type": "Users" } Users (Response with data) @@ -319,25 +325,26 @@ Additional types for session listing and notifications "Type": "Online", "Id": "1", "Ua": "Firefox 27", - "Status": {...} + "Status": {...}, + "Prio": 100 }, { "Type": "Online", "Id": "3", "Userid": "u3", "Ua": "Chrome 28", - "Status": {...} + "Status": {...}, + "Prio": 100 }, { "Type": "Online", "Id": "4", "Userid": "u4", "Ua": "Chrome 28", - "Status": {...} + "Status": {...}, + "Prio": 100 } - ], - "Index": 0, - "Batch": 0 + ] } Note: The Userid field is only present, if that session belongs to a known user. @@ -385,6 +392,47 @@ User authorization and session authentication the session (disconnect) and forget the token. +Information retrieval + + Sessions (Request uses Id, Token and Type) + + { + "Type": "Sessions", + "Sessions": { + "Type": "Token type", + "Token": "Request token" + } + } + + Valid known token types are: "contact". + + Sessions (Response with Id, Token and Type from request and + populated Session list). + + { + "Type": "Sessions", + "Sessions": { + "Type": "Type as in request", + "Token": "Token as in request" + }, + "Users": [ + { + "Type": "Online", + "Id": "1", + "Ua": "Firefox 27", + "Status": {...} + }, + { + "Type": "Online", + "Id": "3", + "Userid": "u3", + "Ua": "Chrome 28", + "Status": {...} + }, ... + ] + } + + Chat messages and status information The chat is used to transfer simple messages ore more complex structures @@ -457,6 +505,48 @@ Chat messages and status information message is shown or not. For file transfer information the message is always "File". + Chat with contact request/confirm information + + Request to create a contact token with Id. + + { + "Message": "Some message", + "Time": "2013-11-20T16:28:42+01:00", + "Status": { + "ContactRequest": { + "Id": "client-generated-id" + } + } + } + + Reply with success (Sever generates and inserts token). + + { + "Message": "Some response message", + "Time": "2013-11-20T16:28:59+01:00", + "Status": { + "ContactRequest": { + "Id": "request-id", + "Success": true, + "Token": "server-generated-token-on-success" + } + } + } + + Or reject (no reply is also possible). + + { + "Message": "Some response message", + "Time": "2013-11-20T16:28:59+01:00", + "Status": { + "ContactRequest": { + "Id": "request-id", + "Success": false + } + } + } + + Chat message deliver status extensions Send chat messages as normal, but add the "Mid" field which is a @@ -536,22 +626,24 @@ Chat messages and status information } - Request being called from audio mixer + Request an automatic callback, by sending a chat message with the AutoCall + document in Status. { "Type": "Chat", "Chat": { - "Message": null, + "Message": "Call me back", "Status": { - "type": "conference", - "id": "my-conference-room" + "AutoCall": { + "Type": "conference", + "Id": "my-conference-room" } } } - This can be sent to any participant that has a "Status" containing the "isMixer" - flag with a "true" value. The peer will then try to establish a peer connection - to the caller which it needs to pick up to join an audio conference. + For example this can be sent to sessions which have "autoCalls" set in session + status. The peer will then try to establish a peer connection to the caller which + the client eeds to pick up automatically. Data channel only messages diff --git a/html/main.html b/html/main.html index 54dad972..0abebc27 100644 --- a/html/main.html +++ b/html/main.html @@ -15,7 +15,7 @@
- + @@ -35,7 +35,7 @@
-
+
diff --git a/server.conf.in b/server.conf.in index 9a9257d1..8b8c1d27 100644 --- a/server.conf.in +++ b/server.conf.in @@ -60,6 +60,11 @@ listen = 127.0.0.1:8080 ; Session secret to use for session id generator. 32 or 64 bytes of random data ; are recommented. sessionSecret = the-default-secret-do-not-keep-me +; Encryption secret protecting the data in generated server side tokens. Use +; 16, 24, or 32 bytes to select AES-128, AES-192, or AES-256. When you change +; the encryption secret, stored authentications, sessions and contacts become +; invalid. +encryptionSecret = tne-default-encryption-block-key ; Full path to a text file containig client tokens which a user needs to enter ; when accessing the web client. Each line in this file represents a valid ; token. diff --git a/src/app/spreed-webrtc-server/channeling.go b/src/app/spreed-webrtc-server/channeling.go index c5e96f3a..726d3005 100644 --- a/src/app/spreed-webrtc-server/channeling.go +++ b/src/app/spreed-webrtc-server/channeling.go @@ -50,6 +50,7 @@ type DataSelf struct { Id string Sid string Userid string + Suserid string Token string Version string Turn *DataTurn @@ -66,12 +67,19 @@ type DataTurn struct { type DataSession struct { Type string Id string - Userid string `json:"Userid,omitempty"` - Ua string `json:"Ua,omitempty"` - Token string `json:"Token,omitempty"` - Version string `json:"Version,omitempty"` - Rev uint64 `json:"Rev,omitempty"` + Userid string `json:",omitempty"` + Ua string `json:",omitempty"` + Token string `json:",omitempty"` + Version string `json:",omitempty"` + Rev uint64 `json:",omitempty"` + Prio int `json:",omitempty"` Status interface{} + stamp int64 +} + +type DataUser struct { + Id string + Sessions int } type DataBye struct { @@ -92,16 +100,41 @@ type DataChat struct { } type DataChatMessage struct { - Mid string Message string Time string - NoEcho bool `json:"NoEcho,omitempty"` - Status interface{} + NoEcho bool `json:",omitempty"` + Mid string `json:",omitempty"` + Status *DataChatStatus } -type DataChatMessageStatus struct { - State string - Mid string +type DataChatStatus struct { + Typing string `json:",omitempty"` + State string `json:",omitempty"` + Mid string `json:",omitempty"` + SeenMids []string `json:",omitempty"` + FileInfo *DataFileInfo `json:",omitempty"` + ContactRequest *DataContactRequest `json:",omitempty"` + AutoCall *DataAutoCall `json:",omitempty"` +} + +type DataFileInfo struct { + Id string `json:"id"` + Chunks uint64 `json:"chunks"` + Name string `json:"name"` + Size uint64 `json:"size"` + Type string `json:"type"` +} + +type DataContactRequest struct { + Id string + Success bool + Userid string `json:",omitempty"` + Token string `json:",omitempty"` +} + +type DataAutoCall struct { + Id string + Type string } type DataIncoming struct { @@ -116,19 +149,26 @@ type DataIncoming struct { Conference *DataConference Alive *DataAlive Authentication *DataAuthentication + Sessions *DataSessions + Iid string `json:",omitempty"` } type DataOutgoing struct { Data interface{} From string To string + Iid string `json:",omitempty"` } type DataSessions struct { + Type string + Sessions *DataSessionsRequest `json:",omitempty"` + Users []*DataSession +} + +type DataSessionsRequest struct { + Token string Type string - Users []*DataSession - Index uint64 - Batch uint64 } type DataConference struct { diff --git a/src/app/spreed-webrtc-server/contact.go b/src/app/spreed-webrtc-server/contact.go new file mode 100644 index 00000000..995f92d6 --- /dev/null +++ b/src/app/spreed-webrtc-server/contact.go @@ -0,0 +1,29 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +package main + +import () + +type Contact struct { + A string + B string +} diff --git a/src/app/spreed-webrtc-server/hub.go b/src/app/spreed-webrtc-server/hub.go index 68ab5b26..777eecbd 100644 --- a/src/app/spreed-webrtc-server/hub.go +++ b/src/app/spreed-webrtc-server/hub.go @@ -23,6 +23,7 @@ package main import ( "bytes" + "crypto/aes" "crypto/hmac" "crypto/sha1" "crypto/sha256" @@ -56,11 +57,13 @@ type HubStat struct { Rooms int `json:"rooms"` Connections int `json:"connections"` Sessions int `json:"sessions"` + Users int `json:"users"` Count uint64 `json:"count"` BroadcastChatMessages uint64 `json:"broadcastchatmessages"` UnicastChatMessages uint64 `json:"unicastchatmessages"` IdsInRoom map[string][]string `json:"idsinroom,omitempty"` SessionsById map[string]*DataSession `json:"sessionsbyid,omitempty"` + UsersById map[string]*DataUser `json:"usersbyid,omitempty"` ConnectionsByIdx map[string]string `json:"connectionsbyidx,omitempty"` } @@ -69,9 +72,11 @@ type Hub struct { connectionTable map[string]*Connection sessionTable map[string]*Session roomTable map[string]*RoomWorker + userTable map[string]*User version string config *Config sessionSecret []byte + encryptionSecret []byte turnSecret []byte tickets *securecookie.SecureCookie count uint64 @@ -83,29 +88,39 @@ type Hub struct { realm string tokenName string useridRetriever func(*http.Request) (string, error) + contacts *securecookie.SecureCookie } -func NewHub(version string, config *Config, sessionSecret, turnSecret, realm string) *Hub { +func NewHub(version string, config *Config, sessionSecret, encryptionSecret, turnSecret, realm string) *Hub { h := &Hub{ - connectionTable: make(map[string]*Connection), - sessionTable: make(map[string]*Session), - roomTable: make(map[string]*RoomWorker), - version: version, - config: config, - sessionSecret: []byte(sessionSecret), - turnSecret: []byte(turnSecret), - realm: realm, + connectionTable: make(map[string]*Connection), + sessionTable: make(map[string]*Session), + roomTable: make(map[string]*RoomWorker), + userTable: make(map[string]*User), + version: version, + config: config, + sessionSecret: []byte(sessionSecret), + encryptionSecret: []byte(encryptionSecret), + turnSecret: []byte(turnSecret), + realm: realm, } if len(h.sessionSecret) < 32 { log.Printf("Weak sessionSecret (only %d bytes). It is recommended to use a key with 32 or 64 bytes.\n", len(h.sessionSecret)) } - h.tickets = securecookie.New(h.sessionSecret, nil) + h.tickets = securecookie.New(h.sessionSecret, h.encryptionSecret) + h.tickets.MaxAge(86400 * 30) // 30 days + h.tickets.HashFunc(sha256.New) + h.tickets.BlockFunc(aes.NewCipher) h.buffers = NewBufferCache(1024, bytes.MinRead) h.buddyImages = NewImageCache() h.tokenName = fmt.Sprintf("token@%s", h.realm) + h.contacts = securecookie.New(h.sessionSecret, h.encryptionSecret) + h.contacts.MaxAge(0) + h.contacts.HashFunc(sha256.New) + h.contacts.BlockFunc(aes.NewCipher) return h } @@ -117,6 +132,7 @@ func (h *Hub) Stat(details bool) *HubStat { Rooms: len(h.roomTable), Connections: len(h.connectionTable), Sessions: len(h.sessionTable), + Users: len(h.userTable), Count: h.count, BroadcastChatMessages: atomic.LoadUint64(&h.broadcastChatMessages), UnicastChatMessages: atomic.LoadUint64(&h.unicastChatMessages), @@ -136,6 +152,11 @@ func (h *Hub) Stat(details bool) *HubStat { sessions[sessionid] = session.Data() } stat.SessionsById = sessions + users := make(map[string]*DataUser) + for userid, user := range h.userTable { + users[userid] = user.Data() + } + stat.UsersById = users connections := make(map[string]string) for id, connection := range h.connectionTable { connections[fmt.Sprintf("%d", connection.Idx)] = id @@ -166,6 +187,16 @@ func (h *Hub) CreateTurnData(id string) *DataTurn { } +func (h *Hub) CreateSuserid(session *Session) (suserid string) { + userid := session.Userid() + if userid != "" { + m := hmac.New(sha256.New, h.encryptionSecret) + m.Write([]byte(userid)) + suserid = base64.StdEncoding.EncodeToString(m.Sum(nil)) + } + return +} + func (h *Hub) CreateSession(request *http.Request, st *SessionToken) *Session { // NOTE(longsleep): Is it required to make this a secure cookie, @@ -183,8 +214,8 @@ func (h *Hub) CreateSession(request *http.Request, st *SessionToken) *Session { if st == nil { sid := NewRandomString(32) id, _ := h.tickets.Encode("id", sid) - session = NewSession(id, sid, userid) - log.Println("Created new session id", len(id), id, sid, userid) + session = NewSession(id, sid) + log.Println("Created new session id", len(id), id, sid) } else { if userid == "" { userid = st.Userid @@ -192,7 +223,11 @@ func (h *Hub) CreateSession(request *http.Request, st *SessionToken) *Session { if !usersEnabled { userid = "" } - session = NewSession(st.Id, st.Sid, userid) + session = NewSession(st.Id, st.Sid) + } + + if userid != "" { + h.authenticateHandler(session, st, userid) } return session @@ -337,8 +372,18 @@ func (h *Hub) unregisterHandler(c *Connection) { return } session := c.Session + suserid := session.Userid() delete(h.connectionTable, c.Id) delete(h.sessionTable, c.Id) + if session != nil && suserid != "" { + user, ok := h.userTable[suserid] + if ok { + empty := user.RemoveSession(session) + if empty { + delete(h.userTable, suserid) + } + } + } h.mutex.Unlock() if session != nil { h.buddyImages.Delete(session.Id) @@ -362,11 +407,11 @@ func (h *Hub) unicastHandler(m *MessageRequest) { } -func (h *Hub) aliveHandler(c *Connection, alive *DataAlive) { +func (h *Hub) aliveHandler(c *Connection, alive *DataAlive, iid string) { aliveJson := h.buffers.New() encoder := json.NewEncoder(aliveJson) - err := encoder.Encode(&DataOutgoing{From: c.Id, Data: alive}) + err := encoder.Encode(&DataOutgoing{From: c.Id, Data: alive, Iid: iid}) if err != nil { log.Println("Alive error while encoding JSON", err) aliveJson.Decref() @@ -377,6 +422,59 @@ func (h *Hub) aliveHandler(c *Connection, alive *DataAlive) { } +func (h *Hub) sessionsHandler(c *Connection, srq *DataSessionsRequest, iid string) { + + var users []*DataSession + + switch srq.Type { + case "contact": + contact := &Contact{} + err := h.contacts.Decode("contact", srq.Token, contact) + if err != nil { + log.Println("Failed to decode incoming contact token", err, srq.Token) + return + } + // Use the userid which is not ours from the contact data. + var userid string + suserid := c.Session.Userid() + if contact.A == suserid { + userid = contact.B + } else if contact.B == suserid { + userid = contact.A + } + if userid == "" { + log.Println("Ignoring foreign contact token", contact.A, contact.B) + return + } + // Find foreign user. + h.mutex.RLock() + defer h.mutex.RUnlock() + user, ok := h.userTable[userid] + if !ok { + return + } + // Add sessions for forein user. + users = user.SessionsData() + default: + log.Println("Unkown incoming sessions request type", srq.Type) + } + + if users != nil { + sessions := &DataSessions{Type: "Sessions", Users: users, Sessions: srq} + sessionsJson := h.buffers.New() + encoder := json.NewEncoder(sessionsJson) + err := encoder.Encode(&DataOutgoing{From: c.Id, Data: sessions, Iid: iid}) + if err != nil { + log.Println("Sessions error while encoding JSON", err) + sessionsJson.Decref() + return + } + c.send(sessionsJson) + sessionsJson.Decref() + } + +} + func (h *Hub) sessionupdateHandler(s *SessionUpdate) uint64 { //fmt.Println("Userupdate", u) @@ -385,7 +483,6 @@ func (h *Hub) sessionupdateHandler(s *SessionUpdate) uint64 { h.mutex.RUnlock() var rev uint64 if ok { - rev = session.Update(s) if s.Status != nil { status, ok := s.Status.(map[string]interface{}) if ok && status["buddyPicture"] != nil { @@ -398,6 +495,7 @@ func (h *Hub) sessionupdateHandler(s *SessionUpdate) uint64 { } } } + rev = session.Update(s) } else { log.Printf("Update data for unknown user %s\n", s.Id) } @@ -423,3 +521,91 @@ func (h *Hub) sessiontokenHandler(st *SessionToken) (string, error) { return nonce, nil } + +func (h *Hub) authenticateHandler(session *Session, st *SessionToken, userid string) error { + + err := session.Authenticate(h.realm, st, userid) + if err == nil { + // Authentication success. + suserid := session.Userid() + h.mutex.Lock() + user, ok := h.userTable[suserid] + if !ok { + user = NewUser(suserid) + h.userTable[suserid] = user + } + h.mutex.Unlock() + user.AddSession(session) + } + + return err + +} + +func (h *Hub) contactrequestHandler(c *Connection, to string, cr *DataContactRequest) error { + + var err error + + if cr.Success { + // Client replied with success. + // Decode Token and make sure c.Session.Userid and the to Session.Userid are a match. + contact := &Contact{} + err = h.contacts.Decode("contact", cr.Token, contact) + if err != nil { + return err + } + suserid := c.Session.Userid() + if suserid == "" { + return errors.New("no userid") + } + h.mutex.RLock() + session, ok := h.sessionTable[to] + h.mutex.RUnlock() + if !ok { + return errors.New("unknown to session for confirm") + } + userid := session.Userid() + if userid == "" { + return errors.New("to has no userid for confirm") + } + if suserid != contact.A { + return errors.New("contact mismatch in a") + } + if userid != contact.B { + return errors.New("contact mismatch in b") + } + } else { + if cr.Token != "" { + // Client replied with no success. + // Remove token. + cr.Token = "" + } else { + // New request. + // Create Token with flag and c.Session.Userid and the to Session.Userid. + suserid := c.Session.Userid() + if suserid == "" { + return errors.New("no userid") + } + h.mutex.RLock() + session, ok := h.sessionTable[to] + h.mutex.RUnlock() + if !ok { + return errors.New("unknown to session") + } + userid := session.Userid() + if userid == "" { + return errors.New("to has no userid") + } + if userid == suserid { + return errors.New("to userid cannot be the same as own userid") + } + // Create object. + contact := &Contact{userid, suserid} + // Serialize. + cr.Token, err = h.contacts.Encode("contact", contact) + } + } + + return err + +} diff --git a/src/app/spreed-webrtc-server/main.go b/src/app/spreed-webrtc-server/main.go index 46d9eebd..3f1111b3 100644 --- a/src/app/spreed-webrtc-server/main.go +++ b/src/app/spreed-webrtc-server/main.go @@ -211,6 +211,11 @@ func runner(runtime phoenix.Runtime) error { return fmt.Errorf("No sessionSecret in config file.") } + encryptionSecret, err := runtime.GetString("app", "encryptionSecret") + if err != nil { + return fmt.Errorf("No encryptionSecret in config file.") + } + tokenFile, err := runtime.GetString("app", "tokenFile") if err == nil { if !httputils.HasFilePath(path.Clean(tokenFile)) { @@ -340,7 +345,7 @@ func runner(runtime phoenix.Runtime) error { computedRealm := fmt.Sprintf("%s.%s", serverRealm, serverToken) // Create our hub instance. - hub := NewHub(runtimeVersion, config, sessionSecret, turnSecret, computedRealm) + hub := NewHub(runtimeVersion, config, sessionSecret, encryptionSecret, turnSecret, computedRealm) // Set number of go routines if it is 1 if goruntime.GOMAXPROCS(0) == 1 { diff --git a/src/app/spreed-webrtc-server/server.go b/src/app/spreed-webrtc-server/server.go index fb45e090..5e3577d0 100644 --- a/src/app/spreed-webrtc-server/server.go +++ b/src/app/spreed-webrtc-server/server.go @@ -44,7 +44,8 @@ func (s *Server) OnRegister(c *Connection) { Type: "Self", Id: c.Id, Sid: c.Session.Sid, - Userid: c.Session.Userid, + Userid: c.Session.Userid(), + Suserid: c.h.CreateSuserid(c.Session), Token: token, Version: c.h.version, Turn: c.h.CreateTurnData(c.Id), @@ -67,7 +68,7 @@ func (s *Server) OnUnregister(c *Connection) { func (s *Server) OnText(c *Connection, b Buffer) { - //log.Printf("OnText from %d: %s\n", c.id, b) + //log.Printf("OnText from %d: %s\n", c.Id, b) var msg DataIncoming err := json.Unmarshal(b.Bytes(), &msg) if err != nil { @@ -106,7 +107,7 @@ func (s *Server) OnText(c *Connection, b Buffer) { // TODO(longsleep): Validate Answer s.Unicast(c, msg.Answer.To, msg.Answer) case "Users": - if c.h.config.DefaultRoomEnabled || !c.h.isDefaultRoomid(c.Roomid) { + if c.Hello { s.Users(c) } case "Authentication": @@ -121,7 +122,7 @@ func (s *Server) OnText(c *Connection, b Buffer) { case "Status": //log.Println("Status", msg.Status) s.UpdateSession(c, &SessionUpdate{Types: []string{"Status"}, Status: msg.Status.Status}) - if c.h.config.DefaultRoomEnabled || !c.h.isDefaultRoomid(c.Roomid) { + if c.Hello { s.Broadcast(c, c.Session.DataSessionStatus()) } case "Chat": @@ -132,16 +133,24 @@ func (s *Server) OnText(c *Connection, b Buffer) { msg.Chat.Chat.Time = time.Now().Format(time.RFC3339) if msg.Chat.To == "" { // TODO(longsleep): Check if chat broadcast is allowed. - if c.h.config.DefaultRoomEnabled || !c.h.isDefaultRoomid(c.Roomid) { + if c.Hello { atomic.AddUint64(&c.h.broadcastChatMessages, 1) s.Broadcast(c, msg.Chat) } } else { + if msg.Chat.Chat.Status != nil && msg.Chat.Chat.Status.ContactRequest != nil { + err = s.ContactRequest(c, msg.Chat.To, msg.Chat.Chat.Status.ContactRequest) + if err != nil { + log.Println("Ignoring invalid contact request.", err) + return + } + msg.Chat.Chat.Status.ContactRequest.Userid = c.Session.Userid() + } atomic.AddUint64(&c.h.unicastChatMessages, 1) s.Unicast(c, msg.Chat.To, msg.Chat) if msg.Chat.Chat.Mid != "" { // Send out delivery confirmation status chat message. - s.Unicast(c, c.Id, &DataChat{To: msg.Chat.To, Type: "Chat", Chat: &DataChatMessage{Mid: msg.Chat.Chat.Mid, Status: &DataChatMessageStatus{State: "sent"}}}) + s.Unicast(c, c.Id, &DataChat{To: msg.Chat.To, Type: "Chat", Chat: &DataChatMessage{Mid: msg.Chat.Chat.Mid, Status: &DataChatStatus{State: "sent"}}}) } } case "Conference": @@ -158,7 +167,9 @@ func (s *Server) OnText(c *Connection, b Buffer) { } } case "Alive": - s.Alive(c, msg.Alive) + s.Alive(c, msg.Alive, msg.Iid) + case "Sessions": + s.Sessions(c, msg.Sessions.Sessions, msg.Iid) default: log.Println("OnText unhandled message type", msg.Type) } @@ -181,9 +192,15 @@ func (s *Server) Unicast(c *Connection, to string, m interface{}) { b.Decref() } -func (s *Server) Alive(c *Connection, alive *DataAlive) { +func (s *Server) Alive(c *Connection, alive *DataAlive, iid string) { - c.h.aliveHandler(c, alive) + c.h.aliveHandler(c, alive, iid) + +} + +func (s *Server) Sessions(c *Connection, srq *DataSessionsRequest, iid string) { + + c.h.sessionsHandler(c, srq, iid) } @@ -194,6 +211,12 @@ func (s *Server) UpdateSession(c *Connection, su *SessionUpdate) uint64 { } +func (s *Server) ContactRequest(c *Connection, to string, cr *DataContactRequest) (err error) { + + return c.h.contactrequestHandler(c, to, cr) + +} + func (s *Server) Broadcast(c *Connection, m interface{}) { b := c.h.buffers.New() @@ -228,9 +251,9 @@ func (s *Server) Users(c *Connection) { func (s *Server) Authenticate(c *Connection, st *SessionToken) bool { - err := c.Session.Authenticate(c.h.realm, st) + err := c.h.authenticateHandler(c.Session, st, "") if err == nil { - log.Println("Authentication success", c.Id, c.Idx, st.Userid) + log.Println("Authentication success", c.Id, c.Idx, c.Session.Userid) return true } else { log.Println("Authentication failed", err, c.Id, c.Idx, st.Userid, st.Nonce) diff --git a/src/app/spreed-webrtc-server/session.go b/src/app/spreed-webrtc-server/session.go index 13dfe2a2..a2ccb07b 100644 --- a/src/app/spreed-webrtc-server/session.go +++ b/src/app/spreed-webrtc-server/session.go @@ -26,6 +26,7 @@ import ( "fmt" "github.com/gorilla/securecookie" "sync" + "time" ) var sessionNonces *securecookie.SecureCookie @@ -33,21 +34,23 @@ var sessionNonces *securecookie.SecureCookie type Session struct { Id string Sid string - Userid string - Roomid string Ua string UpdateRev uint64 Status interface{} Nonce string + Prio int mutex sync.RWMutex + userid string + stamp int64 } -func NewSession(id, sid, userid string) *Session { +func NewSession(id, sid string) *Session { return &Session{ - Id: id, - Sid: sid, - Userid: userid, + Id: id, + Sid: sid, + Prio: 100, + stamp: time.Now().Unix(), } } @@ -61,12 +64,12 @@ func (s *Session) Update(update *SessionUpdate) uint64 { //fmt.Println("type update", key) switch key { - case "Roomid": - s.Roomid = update.Roomid case "Ua": s.Ua = update.Ua case "Status": s.Status = update.Status + case "Prio": + s.Prio = update.Prio } } @@ -76,19 +79,6 @@ func (s *Session) Update(update *SessionUpdate) uint64 { } -func (s *Session) Apply(st *SessionToken) uint64 { - - s.mutex.Lock() - defer s.mutex.Unlock() - s.Id = st.Id - s.Sid = st.Sid - s.Userid = st.Userid - - s.UpdateRev++ - return s.UpdateRev - -} - func (s *Session) Authorize(realm string, st *SessionToken) (string, error) { s.mutex.Lock() @@ -97,7 +87,7 @@ func (s *Session) Authorize(realm string, st *SessionToken) (string, error) { if s.Id != st.Id || s.Sid != st.Sid { return "", errors.New("session id mismatch") } - if s.Userid != "" { + if s.userid != "" { return "", errors.New("session already authenticated") } @@ -109,35 +99,41 @@ func (s *Session) Authorize(realm string, st *SessionToken) (string, error) { } -func (s *Session) Authenticate(realm string, st *SessionToken) error { +func (s *Session) Authenticate(realm string, st *SessionToken, userid string) error { s.mutex.Lock() defer s.mutex.Unlock() - if s.Userid != "" { + if s.userid != "" { return errors.New("session already authenticated") } - if s.Nonce == "" || s.Nonce != st.Nonce { - return errors.New("nonce validation failed") - } - var userid string - err := sessionNonces.Decode(fmt.Sprintf("%s@%s", s.Sid, realm), st.Nonce, &userid) - if err != nil { - return err - } - if st.Userid != userid { - return errors.New("user id mismatch") + if userid == "" { + if s.Nonce == "" || s.Nonce != st.Nonce { + return errors.New("nonce validation failed") + } + err := sessionNonces.Decode(fmt.Sprintf("%s@%s", s.Sid, realm), st.Nonce, &userid) + if err != nil { + return err + } + if st.Userid != userid { + return errors.New("user id mismatch") + } + s.Nonce = "" } - s.Nonce = "" - s.Userid = st.Userid + s.userid = userid + s.stamp = time.Now().Unix() s.UpdateRev++ return nil } func (s *Session) Token() *SessionToken { - return &SessionToken{Id: s.Id, Sid: s.Sid, Userid: s.Userid} + + s.mutex.RLock() + defer s.mutex.RUnlock() + + return &SessionToken{Id: s.Id, Sid: s.Sid, Userid: s.userid} } func (s *Session) Data() *DataSession { @@ -147,14 +143,22 @@ func (s *Session) Data() *DataSession { return &DataSession{ Id: s.Id, - Userid: s.Userid, + Userid: s.userid, Ua: s.Ua, Status: s.Status, Rev: s.UpdateRev, + Prio: s.Prio, + stamp: s.stamp, } } +func (s *Session) Userid() string { + + return s.userid + +} + func (s *Session) DataSessionLeft(state string) *DataSession { s.mutex.RLock() @@ -176,8 +180,9 @@ func (s *Session) DataSessionJoined() *DataSession { return &DataSession{ Type: "Joined", Id: s.Id, - Userid: s.Userid, + Userid: s.userid, Ua: s.Ua, + Prio: s.Prio, } } @@ -190,9 +195,10 @@ func (s *Session) DataSessionStatus() *DataSession { return &DataSession{ Type: "Status", Id: s.Id, - Userid: s.Userid, + Userid: s.userid, Status: s.Status, Rev: s.UpdateRev, + Prio: s.Prio, } } @@ -202,6 +208,7 @@ type SessionUpdate struct { Types []string Roomid string Ua string + Prio int Status interface{} } diff --git a/src/app/spreed-webrtc-server/user.go b/src/app/spreed-webrtc-server/user.go new file mode 100644 index 00000000..057e37a6 --- /dev/null +++ b/src/app/spreed-webrtc-server/user.go @@ -0,0 +1,112 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "sort" + "sync" +) + +type User struct { + Id string + sessionTable map[string]*Session + mutex sync.RWMutex +} + +func NewUser(id string) *User { + + user := &User{ + Id: id, + sessionTable: make(map[string]*Session), + } + return user + +} + +// Return true if first session. +func (u *User) AddSession(s *Session) bool { + first := false + u.mutex.Lock() + u.sessionTable[s.Id] = s + if len(u.sessionTable) == 1 { + fmt.Println("First session registered for user", u.Id) + first = true + } + u.mutex.Unlock() + return first +} + +// Return true if no session left. +func (u *User) RemoveSession(s *Session) bool { + last := false + u.mutex.Lock() + delete(u.sessionTable, s.Id) + if len(u.sessionTable) == 0 { + fmt.Println("Last session unregistered for user", u.Id) + last = true + } + u.mutex.Unlock() + return last +} + +func (u *User) Data() *DataUser { + u.mutex.RLock() + defer u.mutex.RUnlock() + return &DataUser{ + Id: u.Id, + Sessions: len(u.sessionTable), + } +} + +func (u *User) SessionsData() []*DataSession { + + sessions := make([]*DataSession, 0, len(u.sessionTable)) + u.mutex.RLock() + defer u.mutex.RUnlock() + for _, session := range u.sessionTable { + sessions = append(sessions, session.Data()) + } + sort.Sort(ByPrioAndStamp(sessions)) + return sessions + +} + +type ByPrioAndStamp []*DataSession + +func (a ByPrioAndStamp) Len() int { + return len(a) +} + +func (a ByPrioAndStamp) Swap(i, j int) { + a[i], a[j] = a[j], a[i] +} + +func (a ByPrioAndStamp) Less(i, j int) bool { + if a[i].Prio < a[j].Prio { + return true + } + if a[i].Prio == a[j].Prio { + return a[i].stamp < a[j].stamp + } + return false +} diff --git a/src/app/spreed-webrtc-server/users.go b/src/app/spreed-webrtc-server/users.go index 4bc552dd..ed70687f 100644 --- a/src/app/spreed-webrtc-server/users.go +++ b/src/app/spreed-webrtc-server/users.go @@ -74,10 +74,11 @@ func (uh *UsersSharedsecretHandler) Get(request *http.Request) (userid string, e func (uh *UsersSharedsecretHandler) Validate(snr *SessionNonceRequest, request *http.Request) (string, error) { // Parse UseridCombo. - useridCombo := strings.SplitN(snr.UseridCombo, ":", 2) - if len(useridCombo) != 2 { + useridCombo := strings.SplitN(snr.UseridCombo, ":", 3) + if len(useridCombo) < 2 { return "", errors.New("invalid useridcombo") } + // TODO(longsleep): Add support for third field which provides the username. expirationString, userid := useridCombo[0], useridCombo[1] expiration, err := strconv.ParseInt(expirationString, 10, 64) diff --git a/src/styles/components/_buddylist.scss b/src/styles/components/_buddylist.scss index d7f7d6fc..8eeaff88 100644 --- a/src/styles/components/_buddylist.scss +++ b/src/styles/components/_buddylist.scss @@ -113,7 +113,7 @@ cursor: pointer; display: block; font-size: 13px; - height: 66px; + min-height: 66px; overflow: hidden; position: relative; text-align: left; @@ -126,15 +126,21 @@ } .buddy { - &.withSubline .buddy1 { + &.withSubline .buddy1, &.contact .buddy1 { top: 15px; } - &.withSubline .buddy2 { + &.withSubline .buddy2, &.contact .buddy2 { display: block; } &.hovered .buddyactions { right: 0; } + & .fa.contact:before { + content: "\f006"; + } + &.contact .fa.contact:before { + content: "\f005"; + } .buddyPicture { background: $actioncolor1; border-radius: 2px; @@ -158,6 +164,16 @@ top: 0; } } + .buddyPictureSmall { + margin:0px; + margin-left:-4px; + height:30px; + width:30px; + margin-right:4px; + .#{$fa-css-prefix} { + line-height: 30px; + } + } .buddy1 { color: $componentfg1; font-weight: bold; @@ -181,11 +197,19 @@ white-space: nowrap; display: none; } + .buddy3 { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + display: inline-block; + width:160px; + text-align:left; + } } .buddy .buddyactions { background: $buddylist-action-background; - bottom: 0px; + height: 66px; line-height: 66px; position: absolute; right: -125px; @@ -199,4 +223,34 @@ .#{$fa-css-prefix} { font-size: 2em; } + i.fa { + pointer-events: none; + } } + +.buddy .buddysessions { + margin-top: 56px; + max-height:0px; + transition-property: max-height; + transition-duration: 1s; + transition-delay: .1s; + ul { + padding-top: 10px; + margin-left: 20px; + padding-left: 0px; + border-left: 1px dotted $bordercolor; + } + ul li { + margin-bottom: 1px; + margin-top: 1px; + margin-left: -4px; + list-style-type: none; + } + .currentsession .buddy3 { + font-weight: bold; + } +} + +.buddy.hovered .buddysessions { + max-height:999px; +} \ No newline at end of file diff --git a/src/styles/components/_chat.scss b/src/styles/components/_chat.scss index 4da09c0b..a76ab562 100644 --- a/src/styles/components/_chat.scss +++ b/src/styles/components/_chat.scss @@ -439,7 +439,7 @@ } } .typinghint { - padding: 2px 6px 0 6px; + padding: 0 6px 0 6px; white-space: nowrap; overflow: hidden; font-size:.8em; diff --git a/src/styles/global/_base.scss b/src/styles/global/_base.scss index cbd1681c..63825c38 100644 --- a/src/styles/global/_base.scss +++ b/src/styles/global/_base.scss @@ -131,4 +131,5 @@ a { font-size: 1.1em; margin-top: 80px; text-shadow: 0 0 5px black; + max-width:500px; } diff --git a/static/js/controllers/chatroomcontroller.js b/static/js/controllers/chatroomcontroller.js index 74c66656..0b814c2e 100644 --- a/static/js/controllers/chatroomcontroller.js +++ b/static/js/controllers/chatroomcontroller.js @@ -18,7 +18,7 @@ * along with this program. If not, see . * */ -define(['underscore', 'moment', 'text!partials/fileinfo.html'], function(_, moment, templateFileInfo) { +define(['underscore', 'moment', 'text!partials/fileinfo.html', 'text!partials/contactrequest.html'], function(_, moment, templateFileInfo, templateContactRequest) { // ChatroomController return ["$scope", "$element", "$window", "safeMessage", "safeDisplayName", "$compile", "$filter", "translation", function($scope, $element, $window, safeMessage, safeDisplayName, $compile, $filter, translation) { @@ -45,6 +45,7 @@ define(['underscore', 'moment', 'text!partials/fileinfo.html'], function(_, mome var displayName = safeDisplayName; var buddyImageSrc = $filter("buddyImageSrc"); var fileInfo = $compile(templateFileInfo); + var contactRequest = $compile(templateContactRequest); var knowMessage = { r: {}, @@ -285,17 +286,17 @@ define(['underscore', 'moment', 'text!partials/fileinfo.html'], function(_, mome $scope.showmessage = function(from, timestamp, message, nodes) { - var userid = $scope.$parent.$parent.id; + var sessonid = $scope.$parent.$parent.id; // Prepare message to display. var s = []; if (message) { s.push(message); - $scope.$emit("incoming", message, from, userid); + $scope.$emit("incoming", message, from, sessonid); } var is_new_message = lastSender !== from; - var is_self = from === userid; + var is_self = from === sessonid; var extra_css = ""; var title = null; @@ -373,7 +374,7 @@ define(['underscore', 'moment', 'text!partials/fileinfo.html'], function(_, mome $scope.$on("received", function(event, from, data) { - var userid = $scope.$parent.$parent.id; + var sessionid = $scope.$parent.$parent.id; var mid = data.Mid || null; switch (data.Type) { @@ -394,9 +395,10 @@ define(['underscore', 'moment', 'text!partials/fileinfo.html'], function(_, mome // Definitions. var message = null; var nodes = null; - var fromself = from === userid; + var fromself = from === sessionid; var noop = false; var element = null; + var subscope; var timestamp = data.Time; if (!timestamp) { @@ -441,7 +443,7 @@ define(['underscore', 'moment', 'text!partials/fileinfo.html'], function(_, mome // File offers. if (data.Status.FileInfo) { - var subscope = $scope.$new(); + subscope = $scope.$new(); subscope.info = data.Status.FileInfo; subscope.from = from; fileInfo(subscope, function(clonedElement, scope) { @@ -451,6 +453,39 @@ define(['underscore', 'moment', 'text!partials/fileinfo.html'], function(_, mome noop = true; } + // Contact request. + if (data.Status.ContactRequest) { + subscope = $scope.$new(); + subscope.request = data.Status.ContactRequest; + subscope.fromself = fromself; + contactRequest(subscope, function(clonedElement, scope) { + var text; + if (fromself) { + if (scope.request.Userid) { + if (scope.request.Success) { + text = translation._("You accepted the contact request."); + } else { + text = translation._("You rejected the contact request."); + } + } else { + text = translation._("You sent a contact request."); + } + } else { + if (scope.request.Success) { + text = translation._("Your contact request was accepted."); + } else{ + if (scope.request.Token) { + text = translation._("Incoming contact request."); + } else { + text = translation._("Your contact request was rejected."); + } + } + } + element = $scope.showmessage(from, timestamp, text, clonedElement); + }); + noop = true; + } + // Ignore unknown status messages. if (message === null && nodes === null) { noop = true; diff --git a/static/js/controllers/controllers.js b/static/js/controllers/controllers.js index 6d3411d8..24b7c56b 100644 --- a/static/js/controllers/controllers.js +++ b/static/js/controllers/controllers.js @@ -24,13 +24,15 @@ define([ 'controllers/mediastreamcontroller', 'controllers/statusmessagecontroller', 'controllers/chatroomcontroller', - 'controllers/roomchangecontroller'], function(_, MediastreamController, StatusmessageController, ChatroomController, RoomchangeController) { + 'controllers/roomchangecontroller', + 'controllers/usersettingscontroller'], function(_, MediastreamController, StatusmessageController, ChatroomController, RoomchangeController, UsersettingsController) { var controllers = { MediastreamController: MediastreamController, StatusmessageController: StatusmessageController, ChatroomController: ChatroomController, - RoomchangeController: RoomchangeController + RoomchangeController: RoomchangeController, + UsersettingsController: UsersettingsController }; var initialize = function(angModule) { diff --git a/static/js/controllers/mediastreamcontroller.js b/static/js/controllers/mediastreamcontroller.js index 17322dcb..1cd9f07f 100644 --- a/static/js/controllers/mediastreamcontroller.js +++ b/static/js/controllers/mediastreamcontroller.js @@ -20,7 +20,7 @@ */ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'webrtc.adapter'], function(_, BigScreen, moment, sjcl) { - return ["$scope", "$rootScope", "$element", "$window", "$timeout", "safeDisplayName", "safeApply", "mediaStream", "appData", "playSound", "desktopNotify", "alertify", "toastr", "translation", "fileDownload", function($scope, $rootScope, $element, $window, $timeout, safeDisplayName, safeApply, mediaStream, appData, playSound, desktopNotify, alertify, toastr, translation, fileDownload) { + return ["$scope", "$rootScope", "$element", "$window", "$timeout", "safeDisplayName", "safeApply", "mediaStream", "appData", "playSound", "desktopNotify", "alertify", "toastr", "translation", "fileDownload", "localStorage", function($scope, $rootScope, $element, $window, $timeout, safeDisplayName, safeApply, mediaStream, appData, playSound, desktopNotify, alertify, toastr, translation, fileDownload, localStorage) { /*console.log("route", $route, $routeParams, $location);*/ @@ -123,6 +123,7 @@ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'webrtc.adapter'], function $scope.status = "initializing"; $scope.id = null; $scope.userid = null; + $scope.suserid = null; $scope.peer = null; $scope.dialing = null; $scope.conference = null; @@ -138,6 +139,7 @@ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'webrtc.adapter'], function $scope.master = { displayName: null, buddyPicture: null, + message: null, settings: { videoQuality: "high", stereo: true, @@ -174,7 +176,8 @@ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'webrtc.adapter'], function // This is the user status. var status = { displayName: $scope.master.displayName || null, - buddyPicture: $scope.master.buddyPicture || null + buddyPicture: $scope.master.buddyPicture || null, + message: $scope.master.message || null } if (_.isEqual(status, cache.status)) { console.log("Status update skipped, as status has not changed.") @@ -344,7 +347,6 @@ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'webrtc.adapter'], function }); // Load stuff from localStorage. - // TODO(longsleep): Put localStorage into Angular service. var storedUser = localStorage.getItem("mediastream-user"); console.log("Found stored user data:", storedUser); if (storedUser) { @@ -371,6 +373,7 @@ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'webrtc.adapter'], function safeApply($scope, function(scope) { scope.id = scope.myid = data.Id; scope.userid = data.Userid; + scope.suserid = data.Suserid; scope.turn = data.Turn; scope.stun = data.Stun; scope.refreshWebrtcSettings(); @@ -402,6 +405,7 @@ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'webrtc.adapter'], function delete data.nonce; }, function(data, status) { console.error("Failed to authorize session", status, data); + mediaStream.users.forget(); }); } } @@ -427,6 +431,8 @@ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'webrtc.adapter'], function }, 0); } + appData.e.triggerHandler("selfReceived", data); + }); mediaStream.webrtc.e.on("peercall", function(event, peercall) { @@ -545,7 +551,7 @@ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'webrtc.adapter'], function if (opts.soft) { return; } - $scope.userid = null; + $scope.userid = $scope.suserid = null; break; case "error": if (reconnecting || connected) { @@ -621,9 +627,12 @@ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'webrtc.adapter'], function }); $scope.$watch("userid", function(userid) { + var suserid; if (userid) { - console.info("Session is now authenticated:", userid); + suserid = $scope.suserid; + console.info("Session is now authenticated:", userid, suserid); } + appData.e.triggerHandler("authenticationChanged", [userid, suserid]); }); // Apply all layout stuff as classes to our element. @@ -659,8 +668,23 @@ define(['underscore', 'bigscreen', 'moment', 'sjcl', 'webrtc.adapter'], function if (mediaStream.connector.connected) { $scope.setStatus("waiting"); } - $scope.layout.buddylist = true; - $scope.layout.buddylistAutoHide = false; + if ($scope.roomstatus) { + $scope.layout.buddylist = true; + $scope.layout.buddylistAutoHide = false; + } else { + $scope.layout.buddylist = false; + $scope.layout.buddylistAutoHide = true; + } + }); + + $scope.$watch("roomstatus", function(roomstatus) { + if (roomstatus && !$scope.peer) { + $scope.layout.buddylist = true; + $scope.layout.buddylistAutoHide = false; + } else if (!$scope.layout.buddylistAutoHide) { + $scope.layout.buddylist = false; + $scope.layout.buddylistAutoHide = true; + } }); mediaStream.webrtc.e.on("busy", function(event, from) { diff --git a/static/js/controllers/usersettingscontroller.js b/static/js/controllers/usersettingscontroller.js new file mode 100644 index 00000000..2bbc5c8d --- /dev/null +++ b/static/js/controllers/usersettingscontroller.js @@ -0,0 +1,66 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +define([], function() { + + // UsersettingsController + return ["$scope", "$element", "mediaStream", "safeApply", function($scope, $element, mediaStream, safeApply) { + + $scope.withUsersForget = true; + + this.registerUserid = function(btn) { + + var successHandler = function(data) { + console.info("Created new userid:", data.userid); + // If the server provided us a nonce, we can do everthing on our own. + mediaStream.users.store(data); + $scope.loadedUserlogin = true; + safeApply($scope); + // Directly authenticate ourselves with the provided nonce. + mediaStream.api.requestAuthentication(data.userid, data.nonce); + delete data.nonce; + }; + + console.log("No userid - creating one ..."); + mediaStream.users.register(btn.form, function(data) { + if (data.nonce) { + successHandler(data); + } else { + // No nonce received. So this means something we cannot do on our own. + // Make are GET request and retrieve nonce that way and let the + // browser/server do the rest. + mediaStream.users.authorize(data, successHandler, function(data, status) { + console.error("Failed to get nonce after create", status, data); + }); + } + }, function(data, status) { + console.error("Failed to create userid", status, data); + }); + + }; + + this.forgetUserid = function() { + mediaStream.users.forget(); + mediaStream.connector.forgetAndReconnect(); + }; + + }]; + +}); diff --git a/static/js/directives/audiolevel.js b/static/js/directives/audiolevel.js index e8f91d02..0ab7de42 100644 --- a/static/js/directives/audiolevel.js +++ b/static/js/directives/audiolevel.js @@ -18,11 +18,10 @@ * along with this program. If not, see . * */ -define(['jquery', 'underscore', 'rAF'], function($, _) { +define(['jquery', 'underscore'], function($, _) { - return ["$window", "mediaStream", "safeApply", function($window, mediaStream, safeApply) { + return ["$window", "mediaStream", "safeApply", "animationFrame", function($window, mediaStream, safeApply, animationFrame) { - var requestAnimationFrame = $window.requestAnimationFrame; var webrtc = mediaStream.webrtc; // Consider anyting lower than this % as no audio. @@ -43,21 +42,22 @@ define(['jquery', 'underscore', 'rAF'], function($, _) { // Own audio level indicator. var element = $element[0]; + var width = 0; this.update = _.bind(function() { - if (this.active) { - requestAnimationFrame(this.update); - } - var width = 0; - if (webrtc.usermedia.audioLevel) { - width = Math.round(100 * webrtc.usermedia.audioLevel); - // Hide low volumes. - if (width < threshhold) { + if (this.active || width > 0) { + if (webrtc.usermedia.audioLevel) { + width = Math.round(100 * webrtc.usermedia.audioLevel); + // Hide low volumes. + if (width < threshhold) { + width = 0; + } + } else { width = 0; } + element.style.width = width + '%'; } - element.style.width = width + '%'; }, this); - this.update(); + animationFrame.register(this.update); // Talking state. this.audioActivityHistory = []; diff --git a/static/js/directives/audiovideo.js b/static/js/directives/audiovideo.js index 160b12c1..60243c1d 100644 --- a/static/js/directives/audiovideo.js +++ b/static/js/directives/audiovideo.js @@ -18,11 +18,10 @@ * along with this program. If not, see . * */ -define(['jquery', 'underscore', 'text!partials/audiovideo.html', 'text!partials/audiovideopeer.html', 'bigscreen', 'injectCSS', 'webrtc.adapter', 'rAF'], function($, _, template, templatePeer, BigScreen) { +define(['jquery', 'underscore', 'text!partials/audiovideo.html', 'text!partials/audiovideopeer.html', 'bigscreen', 'injectCSS', 'webrtc.adapter'], function($, _, template, templatePeer, BigScreen) { - return ["$window", "$compile", "$filter", "mediaStream", "safeApply", "desktopNotify", "buddyData", "videoWaiter", "videoLayout", function($window, $compile, $filter, mediaStream, safeApply, desktopNotify, buddyData, videoWaiter, videoLayout) { + return ["$window", "$compile", "$filter", "mediaStream", "safeApply", "desktopNotify", "buddyData", "videoWaiter", "videoLayout", "animationFrame", function($window, $compile, $filter, mediaStream, safeApply, desktopNotify, buddyData, videoWaiter, videoLayout, animationFrame) { - var requestAnimationFrame = $window.requestAnimationFrame; var peerTemplate = $compile(templatePeer); var controller = ['$scope', '$element', '$attrs', function($scope, $element, $attrs) { @@ -316,9 +315,8 @@ define(['jquery', 'underscore', 'text!partials/audiovideo.html', 'text!partials/ needsRedraw = false; redraw(); } - requestAnimationFrame(update); } - _.defer(update); + animationFrame.register(update); } diff --git a/static/js/directives/buddylist.js b/static/js/directives/buddylist.js index 515b311d..e3c71d5f 100644 --- a/static/js/directives/buddylist.js +++ b/static/js/directives/buddylist.js @@ -21,14 +21,14 @@ define(['underscore', 'text!partials/buddylist.html'], function(_, template) { // buddyList - return ["$compile", "buddyList", "mediaStream", function($compile, buddyList, mediaStream) { + return ["$compile", "buddyList", "mediaStream", "contacts", function($compile, buddyList, mediaStream, contacts) { //console.log("buddyList directive"); var controller = ['$scope', '$element', '$attrs', function($scope, $element, $attrs) { $scope.layout.buddylist = false; - $scope.enabled = false; + $scope.layout.buddylistAutoHide = true; $scope.doCall = function(id) { @@ -46,33 +46,44 @@ define(['underscore', 'text!partials/buddylist.html'], function(_, template) { }; + $scope.doContactRequest = function(id) { + + //console.log("doContact", id); + $scope.$emit("requestcontact", id, { + restore: true + }); + + }; + + $scope.doContactRemove = function(userid) { + + contacts.remove(userid); + + }; + + /* $scope.doAudioConference = function(id) { $scope.updateAutoAccept(id); mediaStream.api.sendChat(id, null, { - type: "conference", - id: mediaStream.connector.roomid + AutoCall: { + Type: "conference", + Id: mediaStream.connector.roomid + } }) - }; + };*/ $scope.setRoomStatus = function(status) { - if (status !== $scope.enabled) { - $scope.enabled = status; - $scope.$emit("roomStatus", status); - } - if (status && !$scope.layout.buddylistAutoHide) { - $scope.layout.buddylist = true - } + $scope.$emit("roomStatus", status); }; - //XXX(longsleep): Debug leftover ?? Remove this. - window.doAudioConference = $scope.doAudioConference; - var buddylist = $scope.buddylist = buddyList.buddylist($element, $scope, {}); var onJoined = _.bind(buddylist.onJoined, buddylist); var onLeft = _.bind(buddylist.onLeft, buddylist); var onStatus = _.bind(buddylist.onStatus, buddylist); + var onContactAdded = _.bind(buddylist.onContactAdded, buddylist); + var onContactRemoved = _.bind(buddylist.onContactRemoved, buddylist); mediaStream.api.e.on("received.userleftorjoined", function(event, dataType, data) { if (dataType === "Left") { onLeft(data); @@ -97,11 +108,17 @@ define(['underscore', 'text!partials/buddylist.html'], function(_, template) { $scope.setRoomStatus(false); buddylist.onClosed(); }); - // Request user list whenever the connection comes ready. mediaStream.connector.ready(function() { mediaStream.api.requestUsers(); }); + // Contacts. + contacts.e.on("contactadded", function(event, data) { + onContactAdded(data); + }); + contacts.e.on("contactremoved", function(event, data) { + onContactRemoved(data); + }); }]; diff --git a/static/js/directives/chat.js b/static/js/directives/chat.js index c40dc455..c58916c1 100644 --- a/static/js/directives/chat.js +++ b/static/js/directives/chat.js @@ -20,7 +20,7 @@ */ define(['underscore', 'text!partials/chat.html', 'text!partials/chatroom.html'], function(_, templateChat, templateChatroom) { - return ["$compile", "safeDisplayName", "mediaStream", "safeApply", "desktopNotify", "translation", "playSound", "fileUpload", "randomGen", "buddyData", "$timeout", function($compile, safeDisplayName, mediaStream, safeApply, desktopNotify, translation, playSound, fileUpload, randomGen, buddyData, $timeout) { + return ["$compile", "safeDisplayName", "mediaStream", "safeApply", "desktopNotify", "translation", "playSound", "fileUpload", "randomGen", "buddyData", "appData", "$timeout", function($compile, safeDisplayName, mediaStream, safeApply, desktopNotify, translation, playSound, fileUpload, randomGen, buddyData, appData, $timeout) { var displayName = safeDisplayName; var group_chat_id = ""; @@ -138,6 +138,33 @@ define(['underscore', 'text!partials/chat.html', 'text!partials/chatroom.html'], }); + $scope.$parent.$on("requestcontact", function(event, id, options) { + + if (id !== group_chat_id) { + // Make sure the contact id is valid. + var ad = appData.get(); + if (!ad.userid) { + // Unable to add contacts as we have no own userid. + console.log("You need to log in to add contacts."); + return; + } + var bd = buddyData.get(id); + if (!bd || !bd.session || !bd.session.Userid || ad.userid === bd.session.Userid) { + console.log("This contact cannot be added."); + return; + } + var subscope = $scope.showRoom(id, { + title: translation._("Chat with") + }, options); + subscope.sendChatServer(id, "Contact request", { + ContactRequest: { + Id: randomGen.random({hex: true}) + } + }); + } + + }); + }]; var compile = function(tElement, tAttrs) { @@ -205,47 +232,59 @@ define(['underscore', 'text!partials/chat.html', 'text!partials/chatroom.html'], subscope.sendChat = function(to, message, status, mid, noloop) { //console.log("send chat", to, scope.peer); var peercall = mediaStream.webrtc.findTargetCall(to); - if (message && !mid) { - mid = randomGen.random({ - hex: true - }); - } if (peercall && peercall.peerconnection.datachannelReady) { subscope.p2p(true); // Send out stuff through data channel. - _.delay(function() { - mediaStream.api.apply("sendChat", { - send: function(type, data) { - // We also send to self, to display our own stuff. - if (!noloop) { - mediaStream.api.received({ - Type: data.Type, - Data: data, - From: mediaStream.api.id, - To: peercall.id - }); - } - return peercall.peerconnection.send(data); - } - })(to, message, status, mid); - }, 100); - + return subscope.sendChatPeer2Peer(peercall, to, message, status, mid, noloop); } else { subscope.p2p(false); - _.delay(function() { - mediaStream.api.send2("sendChat", function(type, data) { + return subscope.sendChatServer(to, message, status, mid, noloop); + } + return mid; + }; + subscope.sendChatPeer2Peer = function(peercall, to, message, status, mid, noloop) { + if (message && !mid) { + mid = randomGen.random({ + hex: true + }); + } + _.delay(function() { + mediaStream.api.apply("sendChat", { + send: function(type, data) { + // We also send to self, to display our own stuff. if (!noloop) { - //console.log("looped to self", type, data); mediaStream.api.received({ Type: data.Type, Data: data, From: mediaStream.api.id, - To: to + To: peercall.id }); } - })(to, message, status, mid); - }, 100); + return peercall.peerconnection.send(data); + } + })(to, message, status, mid); + }, 100); + return mid; + }; + subscope.sendChatServer = function(to, message, status, mid, noloop) { + if (message && !mid) { + mid = randomGen.random({ + hex: true + }); } + _.delay(function() { + mediaStream.api.send2("sendChat", function(type, data) { + if (!noloop) { + //console.log("looped to self", type, data); + mediaStream.api.received({ + Type: data.Type, + Data: data, + From: mediaStream.api.id, + To: to + }); + } + })(to, message, status, mid); + }, 100); return mid; }; subscope.p2p = function(state) { @@ -295,8 +334,8 @@ define(['underscore', 'text!partials/chat.html', 'text!partials/chatroom.html'], } safeApply(subscope); }); - subscope.$on("incoming", function(event, message, from, userid) { - if (from !== userid) { + subscope.$on("incoming", function(event, message, from, sessionid) { + if (from !== sessionid) { subscope.pending++; scope.$emit("chatincoming", subscope.id); } @@ -304,7 +343,7 @@ define(['underscore', 'text!partials/chat.html', 'text!partials/chatroom.html'], var room = event.targetScope.id; // Make sure we are not in group chat or the message is from ourselves // before we beep and shout. - if (!subscope.isgroupchat && from !== userid) { + if (!subscope.isgroupchat && from !== sessionid) { playSound.play("message1"); desktopNotify.notify(translation._("Message from ") + displayName(from), message); } diff --git a/static/js/directives/contactrequest.js b/static/js/directives/contactrequest.js new file mode 100644 index 00000000..ef5358a7 --- /dev/null +++ b/static/js/directives/contactrequest.js @@ -0,0 +1,72 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +define(['jquery', 'underscore'], function($, _) { + + return ["translation", "buddyData", "contacts", function(translation, buddyData, contacts) { + + var controller = ['$scope', '$element', '$attrs', function($scope, $element, $attrs) { + + $scope.state = "request"; + $scope.doAccept = function() { + $scope.state = "accepted"; + $scope.doContact(true); + }; + + $scope.doReject = function() { + $scope.state = "rejected"; + $scope.doContact(false); + }; + + $scope.doContact = function(success) { + var r = $scope.request; + r.Success = !!success; + $scope.sendChatServer($scope.id, "Contact request answer", { + ContactRequest: r + }); + }; + + $scope.addContact = function(request, status) { + contacts.add(request, status) + }; + + // Add support for contacts on controller creation. + var request = $scope.request; + if (request.Success && request.Userid && request.Token) { + var buddy = buddyData.lookup($scope.id); + var status = {}; + if (buddy) { + $.extend(status, buddy.status); + } + $scope.addContact(request, status); + } + + }]; + + return { + scope: true, + restrict: 'EAC', + controller: controller, + replace: false + } + + }]; + +}); diff --git a/static/js/directives/directives.js b/static/js/directives/directives.js index 510fd6e3..9b4f2ff4 100644 --- a/static/js/directives/directives.js +++ b/static/js/directives/directives.js @@ -35,7 +35,8 @@ define([ 'directives/screenshare', 'directives/roombar', 'directives/socialshare', - 'directives/page'], function(_, onEnter, onEscape, statusMessage, buddyList, buddyPicture, settings, chat, audioVideo, usability, audioLevel, fileInfo, screenshare, roomBar, socialShare, page) { + 'directives/page', + 'directives/contactrequest'], function(_, onEnter, onEscape, statusMessage, buddyList, buddyPicture, settings, chat, audioVideo, usability, audioLevel, fileInfo, screenshare, roomBar, socialShare, page, contactRequest) { var directives = { onEnter: onEnter, @@ -52,7 +53,8 @@ define([ screenshare: screenshare, roomBar: roomBar, socialShare: socialShare, - page: page + page: page, + contactRequest: contactRequest }; var initialize = function(angModule) { diff --git a/static/js/directives/settings.js b/static/js/directives/settings.js index 6716aa05..7d36757e 100644 --- a/static/js/directives/settings.js +++ b/static/js/directives/settings.js @@ -22,7 +22,7 @@ define(['jquery', 'underscore', 'text!partials/settings.html'], function($, _, t return ["$compile", "mediaStream", function($compile, mediaStream) { - var controller = ['$scope', 'desktopNotify', 'mediaSources', 'safeApply', 'availableLanguages', 'translation', function($scope, desktopNotify, mediaSources, safeApply, availableLanguages, translation) { + var controller = ['$scope', 'desktopNotify', 'mediaSources', 'safeApply', 'availableLanguages', 'translation', 'localStorage', function($scope, desktopNotify, mediaSources, safeApply, availableLanguages, translation, localStorage) { $scope.layout.settings = false; $scope.showAdvancedSettings = true; @@ -74,42 +74,6 @@ define(['jquery', 'underscore', 'text!partials/settings.html'], function($, _, t }); }; - $scope.registerUserid = function(btn) { - - var successHandler = function(data) { - console.info("Created new userid:", data.userid); - // If the server provided us a nonce, we can do everthing on our own. - mediaStream.users.store(data); - $scope.loadedUserlogin = true; - safeApply($scope); - // Directly authenticate ourselves with the provided nonce. - mediaStream.api.requestAuthentication(data.userid, data.nonce); - delete data.nonce; - }; - - console.log("No userid - creating one ..."); - mediaStream.users.register(btn.form, function(data) { - if (data.nonce) { - successHandler(data); - } else { - // No nonce received. So this means something we cannot do on our own. - // Make are GET request and retrieve nonce that way and let the - // browser/server do the rest. - mediaStream.users.authorize(data, successHandler, function(data, status) { - console.error("Failed to get nonce after create", status, data); - }); - } - }, function(data, status) { - console.error("Failed to create userid", status, data); - }); - - }; - - $scope.forgetUserid = function() { - mediaStream.users.forget(); - mediaStream.connector.forgetAndReconnect(); - }; - $scope.checkDefaultMediaSources = function() { if ($scope.master.settings.microphoneId && !$scope.mediaSources.hasAudioId($scope.master.settings.microphoneId)) { $scope.master.settings.microphoneId = null; diff --git a/static/js/directives/usability.js b/static/js/directives/usability.js index a75aa58a..aace59a3 100644 --- a/static/js/directives/usability.js +++ b/static/js/directives/usability.js @@ -24,7 +24,7 @@ define(['jquery', 'underscore', 'text!partials/usability.html'], function($, _, return ["mediaStream", function(mediaStream) { - var controller = ['$scope', "mediaStream", "safeApply", "$timeout", function($scope, mediaStream, safeApply, $timeout) { + var controller = ['$scope', "mediaStream", "safeApply", "$timeout", "localStorage", function($scope, mediaStream, safeApply, $timeout, localStorage) { var pending = true; var complete = false; diff --git a/static/js/filters/buddyimagesrc.js b/static/js/filters/buddyimagesrc.js index cded8615..852a71c0 100644 --- a/static/js/filters/buddyimagesrc.js +++ b/static/js/filters/buddyimagesrc.js @@ -79,18 +79,18 @@ define(["underscore"], function(_) { var scope = buddyData.lookup(id); if (scope) { - var status = scope.status; - if (status) { - if (status.buddyPictureLocalUrl) { - return status.buddyPictureLocalUrl; - } else if (status.buddyPicture) { + var display = scope.display; + if (display) { + if (display.buddyPictureLocalUrl) { + return display.buddyPictureLocalUrl; + } else if (display.buddyPicture) { var url = urls[id]; if (url) { revokeURL(id, url); } // New data -> new url. - var blob = dataURLToBlob(status.buddyPicture); - url = status.buddyPictureLocalUrl = urls[id] = blobToObjectURL(blob); + var blob = dataURLToBlob(display.buddyPicture); + url = display.buddyPictureLocalUrl = urls[id] = blobToObjectURL(blob); return url; } } diff --git a/static/js/filters/displayname.js b/static/js/filters/displayname.js index ee104a19..f3695968 100644 --- a/static/js/filters/displayname.js +++ b/static/js/filters/displayname.js @@ -35,8 +35,8 @@ define([], function() { } var scope = buddyData.lookup(id); if (scope) { - if (scope.displayName) { - return scope.displayName; + if (scope.display.displayName) { + return scope.display.displayName; } return user_text + " " + scope.buddyIndex; } else { diff --git a/static/js/mediastream/api.js b/static/js/mediastream/api.js index 7ed60a44..c7f8b110 100644 --- a/static/js/mediastream/api.js +++ b/static/js/mediastream/api.js @@ -29,6 +29,7 @@ define(['jquery', 'underscore'], function($, _) { this.sid = null; this.session = {}; this.connector = connector; + this.iids= 0; this.e = $({}); @@ -74,7 +75,7 @@ define(['jquery', 'underscore'], function($, _) { Type: type }; payload[type] = data; - //console.log("<<<<<<<<<<<<", JSON.stringify(payload)); + //console.log("<<<<<<<<<<<<", JSON.stringify(payload, null, 2)); this.connector.send(payload, noqueue); }; @@ -91,6 +92,21 @@ define(['jquery', 'underscore'], function($, _) { return this.apply(name, obj); }; + Api.prototype.request = function(type, data, cb) { + + var payload = { + Type: type + } + payload[type] = data; + if (cb) { + var iid = ""+(this.iids++); + payload.Iid = iid; + this.e.one(iid+".request", cb); + } + this.connector.send(payload); + + } + // Helper hack function to send API requests to other destinations. // Simply provide an alternative send function on the obj Object. Api.prototype.apply = function(name, obj) { @@ -105,9 +121,16 @@ define(['jquery', 'underscore'], function($, _) { this.last_receive = now; this.last_receive_overdue = false; + var iid = d.Iid; var data = d.Data; var dataType = data.Type; + if (iid) { + // Shortcut for iid registered responses. + this.e.triggerHandler(iid+".request", [dataType, data]); + return; + } + switch (dataType) { case "Self": console.log("Self received", data); @@ -321,6 +344,20 @@ define(['jquery', 'underscore'], function($, _) { return this.send("Alive", data); }; + Api.prototype.sendSessions = function(token, type, cb) { + + var data = { + Type: "Sessions", + Sessions: { + Type: type, + Token: token + } + } + + return this.request("Sessions", data, cb); + + }; + return Api; }); diff --git a/static/js/services/animationframe.js b/static/js/services/animationframe.js new file mode 100644 index 00000000..dd6f43b4 --- /dev/null +++ b/static/js/services/animationframe.js @@ -0,0 +1,51 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +define(["underscore", "rAF"], function(_) { + + // animationFrame + return ["$window", function($window) { + + var requestAnimationFrame = $window.requestAnimationFrame; + var registry = []; + + var caller = function(f) { + f(); + }; + var worker = function() { + registry.forEach(caller) + requestAnimationFrame(worker); + }; + + // Public api. + var animationFrame = { + register: function(f) { + registry.push(f); + } + }; + + // Auto start worker. + _.defer(worker); + + return animationFrame; + + }]; + +}); diff --git a/static/js/services/appdata.js b/static/js/services/appdata.js index 2344904c..de688591 100644 --- a/static/js/services/appdata.js +++ b/static/js/services/appdata.js @@ -18,13 +18,18 @@ * along with this program. If not, see . * */ -define([], function() { +define(["jquery"], function($) { + + // appData.e events + // - authenticationChanged(userid) + // - selfReceived(self) // appData return [function() { var data = { - data: null + data: null, + e: $({}) } var appData = { get: function() { @@ -33,7 +38,8 @@ define([], function() { set: function(d) { data.data = d; return d; - } + }, + e: data.e } return appData; diff --git a/static/js/services/buddydata.js b/static/js/services/buddydata.js index 16a2447e..26f68052 100644 --- a/static/js/services/buddydata.js +++ b/static/js/services/buddydata.js @@ -21,7 +21,7 @@ define(['underscore'], function(underscore) { // buddyData - return [function() { + return ["contactData", function(contactData) { var scopes = {}; var brain = {}; @@ -61,16 +61,35 @@ define(['underscore'], function(underscore) { } return 0; }, - get: function(id, createInParent, afterCreateCallback) { + get: function(id, createInParent, afterCreateCallback, userid) { if (scopes.hasOwnProperty(id)) { + //console.log("found id scope", id); return scopes[id]; } else if (!createInParent && pushed.hasOwnProperty(id)) { return pushed[id].scope; } else { + var scope; + if (userid && scopes.hasOwnProperty(userid)) { + scope = scopes[userid]; + if (createInParent) { + scopes[id] = scope; + } + //console.log("found userid scope", userid); + return scope; + } if (createInParent) { + //console.log("creating scope", id, userid); // If we have a parent we can create a new scope. - var scope = scopes[id] = createInParent.$new(); + scope = scopes[id] = createInParent.$new(); + if (userid) { + scopes[userid] = scope; + } scope.buddyIndex = ++count; + if (userid) { + scope.contact = contactData.get(userid); + } else { + scope.contact = null; + } scope.buddyIndexSortable = ("0000000" + scope.buddyIndex).slice(-7); if (pushed.hasOwnProperty(id)) { // Refresh pushed scope reference. @@ -101,13 +120,17 @@ define(['underscore'], function(underscore) { del: function(id, hard) { var scope = scopes[id]; if (scope) { - scope.$destroy(); - brain[id] = scope; + if (!hard) { + brain[id] = scope; + } delete scopes[id]; return scope; } else { return null; } + }, + set: function(id, scope) { + scopes[id] = scope; } }; return buddyData; diff --git a/static/js/services/buddylist.js b/static/js/services/buddylist.js index 4b4b9f57..4ab1224a 100644 --- a/static/js/services/buddylist.js +++ b/static/js/services/buddylist.js @@ -18,7 +18,7 @@ * along with this program. If not, see . * */ -define(['underscore', 'modernizr', 'avltree', 'text!partials/buddy.html', 'text!partials/buddyactions.html', 'text!partials/buddyactionsforaudiomixer.html', 'rAF'], function(_, Modernizr, AvlTree, templateBuddy, templateBuddyActions, templateBuddyActionsForAudioMixer) { +define(['underscore', 'modernizr', 'avltree', 'text!partials/buddy.html', 'text!partials/buddyactions.html', 'text!partials/buddyactionsforaudiomixer.html'], function(_, Modernizr, AvlTree, templateBuddy, templateBuddyActions, templateBuddyActionsForAudioMixer) { var BuddyTree = function() { @@ -31,7 +31,8 @@ define(['underscore', 'modernizr', 'avltree', 'text!partials/buddy.html', 'text! BuddyTree.prototype.create = function(id, scope) { - var sort = scope.displayName ? scope.displayName : "session " + scope.buddyIndexSortable + " " + id; + var display = scope.display || {}; + var sort = display.displayName ? display.displayName : "session " + scope.buddyIndexSortable + " " + id; var data = { id: id, sort: sort + "z" + id @@ -66,6 +67,12 @@ define(['underscore', 'modernizr', 'avltree', 'text!partials/buddy.html', 'text! }; + BuddyTree.prototype.check = function(id) { + + return this.data.hasOwnProperty(id); + + }; + /** * Returns undefined when no change required. Position result otherwise. */ @@ -102,6 +109,18 @@ define(['underscore', 'modernizr', 'avltree', 'text!partials/buddy.html', 'text! }; + BuddyTree.prototype.traverse = function(cb) { + + return this.tree.inOrderTraverse(cb); + + }; + + BuddyTree.prototype.keys = function() { + + return _.keys(this.data); + + }; + BuddyTree.prototype.clear = function() { this.tree.clear(); @@ -110,9 +129,7 @@ define(['underscore', 'modernizr', 'avltree', 'text!partials/buddy.html', 'text! }; // buddyList - return ["$window", "$compile", "playSound", "buddyData", "fastScroll", "mediaStream", function($window, $compile, playSound, buddyData, fastScroll, mediaStream) { - - var requestAnimationFrame = $window.requestAnimationFrame; + return ["$window", "$compile", "playSound", "buddyData", "buddySession", "fastScroll", "mediaStream", "animationFrame", "$q", function($window, $compile, playSound, buddyData, buddySession, fastScroll, mediaStream, animationFrame, $q) { var buddyTemplate = $compile(templateBuddy); var buddyActions = $compile(templateBuddyActions); @@ -138,25 +155,24 @@ define(['underscore', 'modernizr', 'avltree', 'text!partials/buddy.html', 'text! $element.on("mouseenter mouseleave", ".buddy", _.bind(function(event) { // Hover handler for on Buddy actions. var buddyElement = $(event.currentTarget); - this.hover(buddyElement, event.type === "mouseenter" ? true : false, buddyElement.scope().session.Id); + this.hover(buddyElement, event.type === "mouseenter" ? true : false); }, this)); $element.on("click", ".buddy", _.bind(function(event) { var buddyElement = $(event.currentTarget); - buddyElement.scope().doDefault(); + this.click(buddyElement, event.target); }, this)); $element.attr("data-xthreshold", "10"); $element.on("swipeleft", ".buddy", _.bind(function(event) { event.preventDefault(); var buddyElement = $(event.currentTarget); - this.hover(buddyElement, !buddyElement.hasClass("hovered"), buddyElement.scope().session.Id); + this.hover(buddyElement, !buddyElement.hasClass("hovered")); }, this)); $window.setInterval(_.bind(this.soundLoop, this), 500); var update = _.bind(function refreshBuddies() { this.refreshBuddies(); - requestAnimationFrame(update); }, this); - requestAnimationFrame(update); + animationFrame.register(update); }; @@ -185,23 +201,63 @@ define(['underscore', 'modernizr', 'avltree', 'text!partials/buddy.html', 'text! }; - Buddylist.prototype.onBuddyScope = function(scope) { + Buddylist.prototype.onBuddyScopeCreated = function(scope, data) { + // Init scope with our stuff. scope.element = null; - scope.doDefault = function() { - var id = scope.session.Id; - if (scope.status.isMixer) { - return scope.doAudioConference(id); - } - return scope.doCall(id); - }; + scope.contact = null; + scope.display = {}; + scope.session = buddySession.create(data); scope.$on("$destroy", function() { + //console.log("destroyed"); scope.element = null; scope.killed = true; }); }; + Buddylist.prototype.onBuddySessionUserid = function(scope, sourceSession) { + + //console.log("session with userid", sourceSession); + + var userid = sourceSession.Userid; + /* + if (userid === scope.userid) { + // The source session has our own userid, ignore it. + + }*/ + var targetScope = buddyData.get(userid); + if (!targetScope) { + // No scope for this userid yet - set us. + buddyData.set(userid, scope); + //console.log("set scope with userid", sourceSession); + return; + } + var session = targetScope.session; + if (sourceSession === session) { + // No action. + //console.log("source session same as target"); + return; + } + // Merge sessions. + session.merge(sourceSession); + // Cleanup old from tree and DOM. + var id = sourceSession.Id; + this.tree.remove(id); + if (targetScope !== scope) { + if (scope.element) { + this.lefts[id] = scope.element; + //console.log("destroying", id, scope.element); + scope.$destroy(); + } + buddyData.set(id, targetScope); + delete this.actionElements[id]; + return targetScope; + } + return; + + }; + Buddylist.prototype.soundLoop = function() { if (this.playSoundLeft) { @@ -335,72 +391,160 @@ define(['underscore', 'modernizr', 'avltree', 'text!partials/buddy.html', 'text! }; + Buddylist.prototype.dumpBuddyPictureToBlob = function(scope, data) { + + if (!data) { + data = this.dumpBuddyPictureToString(scope); + if (!data) { + return null; + } + } + // NOTE(longsleep): toBlob is not widely supported narf .. + // see: https://code.google.com/p/chromium/issues/detail?id=67587 + var parts = data.match(/data:([^;]*)(;base64)?,([0-9A-Za-z+\/]+)/); + var binStr = atob(parts[3]); + var buf = new ArrayBuffer(binStr.length); + var view = new Uint8Array(buf); + for (var i = 0; i < view.length; i++) { + view[i] = binStr.charCodeAt(i); + } + return new Blob([view], {'type': parts[1]}); + + }; + + Buddylist.prototype.dumpBuddyPictureToString = function(scope) { + + var img = scope.element.find(".buddyPicture img").get(0); + if (img) { + var canvas = $window.document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + var ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); + return canvas.toDataURL("image/jpeg"); + } + return null; + + }; + + Buddylist.prototype.setDisplay = function(id, scope, data, queueName) { + + var status = data.Status; + var display = scope.display; + // Set display.name. + display.displayName = status.displayName; + // Set display.picture. + display.buddyPicture = status.buddyPicture; + this.updateBuddyPicture(display); + // Set display subline. + this.updateSubline(display, status.message); + // Add to render queue when no element exists. + if (!scope.element) { + var before = this.tree.add(id, scope); + this.queue.push([queueName, id, before]); + this.playSoundJoined = true; + } + + }; + + Buddylist.prototype.updateDisplay = function(id, scope, data, queueName) { + + var status = data.Status; + var display = scope.display; + // Update display name. + var displayName = display.displayName; + if (status.displayName) { + display.displayName = status.displayName; + } else { + display.displayName = null; + } + // Add to status queue if sorting has changed. + if (displayName !== status.displayName) { + var before = this.tree.update(id, scope); + this.queue.push([queueName, id, before]); + } + // Update display subline. + if (status.message) { + this.updateSubline(display, status.message); + } + // Update display picture. + if (status.buddyPicture) { + display.buddyPicture = status.buddyPicture || null; + this.updateBuddyPicture(display); + } + + }; + + Buddylist.prototype.updateSubline = function(display, s) { + + if (!s || s === "__e") { + display.subline = ""; + return; + } + if (s.length > 20) { + display.sublineFull = s; + s = s.substr(0, 20) + "..."; + } else { + display.sublineFull = null; + } + display.subline = s; + + }; + Buddylist.prototype.onStatus = function(data) { - //console.log("onStatus", status); + //console.log("onStatus", data); var id = data.Id; - var scope = buddyData.get(id, this.$scope, _.bind(this.onBuddyScope, this)); + var scope = buddyData.get(id, this.$scope, _.bind(function(scope) { + this.onBuddyScopeCreated(scope, data); + }, this), data.Userid); // Update session. - scope.session.Userid = data.Userid; - scope.session.Rev = data.Rev; - // Update status. - if (scope.status && scope.status.Rev >= data.Rev) { - console.warn("Received old status update in status", data.Rev, scope.status.Rev); - } else { - scope.status = data.Status; - this.updateBuddyPicture(scope.status); - var displayName = scope.displayName; - if (scope.status.displayName) { - scope.displayName = scope.status.displayName; - } else { - scope.displayName = null; - } - if (displayName !== scope.displayName) { - var before = this.tree.update(id, scope); - this.queue.push(["status", id, before]); + var sessionData = scope.session.update(id, data, _.bind(function(session) { + //console.log("Session is now authenticated", session); + var newscope = this.onBuddySessionUserid(scope, session); + if (newscope) { + scope = newscope; } - scope.$apply(); + }, this)); + if (sessionData) { + // onStatus for main session. + this.updateDisplay(id, scope, sessionData, "status"); } + scope.$apply(); + return scope; }; - Buddylist.prototype.onJoined = function(data) { + Buddylist.prototype.onJoined = function(data, noApply) { //console.log("Joined", data); var id = data.Id; - var scope = buddyData.get(id, this.$scope, _.bind(this.onBuddyScope, this)); - // Create session. - scope.session = { - Id: data.Id, - Userid: data.Userid, - Ua: data.Ua, - Rev: 0 - }; - // Add status. + var scope = buddyData.get(id, this.$scope, _.bind(function(scope) { + this.onBuddyScopeCreated(scope, data); + }, this), data.Userid); + // Update session. buddyCount++; - if (data.Status) { - if (scope.status && scope.status.Rev >= data.Status.Rev) { - console.warn("Received old status update in join", data.Status.Rev, scope.status.Rev); - } else { - scope.status = data.Status; - scope.displayName = scope.status.displayName; - this.updateBuddyPicture(scope.status); + var sessionData = scope.session.update(id, data, _.bind(function(session) { + //console.log("Session is now authenticated", session); + var newscope = this.onBuddySessionUserid(scope, session); + if (newscope) { + scope = newscope; } + }, this)); + if (sessionData && sessionData.Status) { + this.setDisplay(id, scope, sessionData, "joined"); + } else if (!noApply) { + scope.$apply(); } - //console.log("Joined scope", scope, scope.element); - if (!scope.element) { - var before = this.tree.add(id, scope); - this.queue.push(["joined", id, before]); - this.playSoundJoined = true; - } + return scope; }; - Buddylist.prototype.onLeft = function(data) { - //console.log("Left", session); + Buddylist.prototype.onLeft = function(data, force, noApply) { + + //console.log("Left", data); var id = data.Id; - this.tree.remove(id); var scope = buddyData.get(id); if (!scope) { //console.warn("Trying to remove buddy with no registered scope", session); @@ -409,30 +553,191 @@ define(['underscore', 'modernizr', 'avltree', 'text!partials/buddy.html', 'text! if (buddyCount > 0) { buddyCount--; } - if (scope.element) { - this.lefts[id] = scope.element; - this.playSoundLeft = true; - } + // Remove current id from tree. + this.tree.remove(id); buddyData.del(id); - delete this.actionElements[id]; + // Remove session. + var session = scope.session; + if ((session.remove(id) && scope.contact === null) || force) { + // No session left. Cleanup. + if (scope.element) { + this.lefts[id] = scope.element; + this.playSoundLeft = true; + } + if (session.Userid) { + buddyData.del(session.Userid, true); + } + delete this.actionElements[id]; + scope.$destroy(); + } else { + // Update display stuff if a session is left. This can + // return no session in case when we got this as contact. + var sessionData = session.get(); + if (sessionData) { + this.updateDisplay(sessionData.Id, scope, sessionData, "status"); + } else if (scope.contact) { + var contact = scope.contact; + // Use it with userid as id in tree. + if (!this.tree.check(contact.Userid)) { + this.tree.add(contact.Userid, scope); + buddyCount++; + } + if (contact.Status !== null && !contact.Status.message) { + // Remove status message. + contact.Status.message = "__e"; + } + this.updateDisplay(contact.Userid, scope, contact, "status"); + } + if (!noApply) { + scope.$apply(); + } + } + return scope; }; Buddylist.prototype.onClosed = function() { //console.log("Closed"); - this.$element.empty(); - buddyCount = 0; - buddyData.clear(); - this.tree.clear(); - this.actionElements = {}; + + // Remove pending stuff from queue. this.queue = []; + // Trigger left events for all sessions. + var data = {}; + var sessions = buddySession.sessions(); + for (var id in sessions) { + if (sessions.hasOwnProperty(id)) { + //console.log("close id", id); + data.Id = id; + this.onLeft(data, false, true); + } + } + + }; + + Buddylist.prototype.onContactAdded = function(contact) { + + //console.log("onContactAdded", contact); + var userid = contact.Userid; + + var scope = buddyData.get(userid); + if (scope) { + scope.contact = contact; + var sessionData = scope.session.get(); + if (sessionData) { + if (contact.Status === null && sessionData.Status) { + // Update contact status with session.Status + var status = contact.Status = _.extend({}, sessionData.Status); + // Remove status message. + delete status.message; + // Convert buddy image. + if (status.buddyPicture) { + var img = this.dumpBuddyPictureToString(scope); + if (img) { + status.buddyPicture = img; + } else { + delete status.buddyPicture; + } + } + console.log("Injected status into contact", contact); + } + this.updateDisplay(sessionData.Id, scope, contact, "status"); + scope.$apply(); + } + } else { + // Create new scope for contact. + scope = this.onJoined({ + Id: contact.Userid, + Userid: contact.Userid, + Status: _.extend({}, contact.Status) + }); + scope.contact = contact; + } + }; - Buddylist.prototype.hover = function(buddyElement, hover, id) { + Buddylist.prototype.onContactRemoved = function(contact) { + + //console.log("onContactRemoved", contact); + var userid = contact.Userid; + + var scope = buddyData.get(userid); + if (scope) { + scope.contact = null; + // Remove with left when no session for this userid. + var sessionData = scope.session.get(); + if (!sessionData) { + // Force left. + this.onLeft({Id: userid}, true); + } + } + + }; + + Buddylist.prototype.click = function(buddyElement, target) { + + //console.log("click handler", buddyElement, target); + var action = $(target).data("action"); + if (!action) { + // Make call the default action. + action = "call"; + } - //console.log("hover handler", event, hover, id); + var scope = buddyElement.scope(); + var session = scope.session; + var contact = scope.contact; + + var promise = (function() { + var deferred = $q.defer(); + var sessionData = session.get() + if (!sessionData) { + // Find session with help of contact. + if (contact && contact.Token) { + mediaStream.api.sendSessions(contact.Token, "contact", function(event, type, data) { + //console.log("oooooooooooooooo", type, data); + if (data.Users && data.Users.length > 0) { + var s = data.Users[0]; + buddyData.set(s.Id, scope); + deferred.resolve(s.Id); + } + }); + } + } else { + deferred.resolve(sessionData.Id); + } + return deferred.promise; + })(); + + switch (action) { + case "call": + promise.then(function(id) { + scope.doCall(id); + }); + break; + case "chat": + promise.then(function(id) { + scope.doChat(id); + }); + break; + case "contact": + if (contact) { + scope.doContactRemove(contact.Userid); + } else { + promise.then(function(id) { + scope.doContactRequest(id); + }); + } + break; + } + + }; + + Buddylist.prototype.hover = function(buddyElement, hover) { + + //console.log("hover handler", buddyElement, hover); + var scope = buddyElement.scope(); + var id = scope.session.Id; var buddy = $(buddyElement); var actionElements = this.actionElements; var elem; @@ -453,11 +758,10 @@ define(['underscore', 'modernizr', 'avltree', 'text!partials/buddy.html', 'text! if (elem) { buddy.addClass("hovered"); } else { - var scope = buddyData.get(id); var template = buddyActions; - if (scope.status.isMixer) { - template = buddyActionsForAudioMixer; - } + //if (scope.status.autoCalls && _.indexOf(scope.status.autoCalls, "conference") !== -1) { + // template = buddyActionsForAudioMixer; + //} //console.log("scope", scope, id); template(scope, _.bind(function(clonedElement, $scope) { actionElements[id] = clonedElement; diff --git a/static/js/services/buddysession.js b/static/js/services/buddysession.js new file mode 100644 index 00000000..d45fcd86 --- /dev/null +++ b/static/js/services/buddysession.js @@ -0,0 +1,191 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +define(["underscore"], function(_) { + + // buddySession + return [function() { + + var sessions = {}; + var serials = 0; + + var BuddySession = function(data) { + this.serial = serials++; + this.sessions = {}; + this.count = 0; + //console.log("creating session", this.serial, data.Id, data.Userid, this); + var id = data.Id; + if (data.Id) { + var userid = data.Userid || null; + if (id === userid) { + // Add as default with userid. + this.use(userid, data); + } else { + // Add as session. + var sessionData = this.add(id, data); + if (userid) { + this.auth(userid, sessionData); + } + } + } else { + this.use(null, {}); + } + }; + + BuddySession.prototype.add = function(id, data) { + this.sessions[id] = data; + if (this.count === 0) { + this.use(id, data); + } + this.count++; + sessions[id] = this; + return data; + }; + + BuddySession.prototype.rm = function(id) { + delete this.sessions[id]; + this.count--; + delete sessions[id]; + }; + + BuddySession.prototype.get = function(id) { + if (!id) { + id = this.Id; + } + return this.sessions[id]; + }; + + BuddySession.prototype.use = function(id, data) { + if (id) { + this.Id = id; + } else { + this.Id = null; + } + //console.log("Use session as default", id, data, this); + }; + + BuddySession.prototype.remove = function(id, onEmptyCallback) { + + this.rm(id); + if (id === this.Id) { + var sessions = this.sessions; + var sessionData; + for (var sd in sessions) { + if (sessions.hasOwnProperty(sd)) { + sessionData = sessions[sd]; + break; + } + } + if (sessionData) { + this.use(sessionData.Id, sessionData); + } else { + //console.log("Last session removed", sessions); + if (this.Userid) { + //console.log("Using userid as session id"); + this.use(this.Userid); + } else { + this.use(null); + } + return true; + } + } + return false; + + }; + + BuddySession.prototype.update = function(id, data, onUseridCallback) { + + var userid = data.Userid; + //console.log("session update", id, userid, this, data); + + var sessionData + if (id === userid) { + // Fake updates from userid ids. + sessionData = data; + } else { + sessionData = this.get(id); + if (!sessionData) { + sessionData = this.add(id, data); + } + } + if (userid) { + this.auth(userid, sessionData, onUseridCallback); + } + if (data !== sessionData) { + if (data.Rev) { + sessionData.Rev = data.Rev; + } + if (data.Status) { + sessionData.Status = data.Status; + } + } + + if (id === this.Id) { + return sessionData; + } else { + return null; + } + + }; + + BuddySession.prototype.auth = function(userid, sessionData, onUseridCallback) { + + if (!this.Userid) { + this.Userid = userid; + //console.log("Session now has a user id", this.Id, userid); + } + // Trigger callback if defined and not triggered before. + if (onUseridCallback && !sessionData.auth) { + onUseridCallback(this); + sessionData.auth = true; + } + + }; + + BuddySession.prototype.merge = function(otherSession) { + if (!this.Userid) { + console.error("Refusing to merge into session as we have no userid", this, otherSession); + return; + } + if (otherSession.Userid !== this.Userid) { + console.error("Refusing to merge other session with different userid", otherSession, this); + return; + } + _.each(otherSession.sessions, _.bind(function(s, id) { + if (this.sessions.hasOwnProperty(id)) { + return; + } + this.add(id, s); + }, this)); + console.log("Merged sessions", this, otherSession); + }; + + return { + create: function(data) { + return new BuddySession(data); + }, + sessions: function() { + return sessions; + } + }; + + }]; + +}); diff --git a/static/js/services/contactdata.js b/static/js/services/contactdata.js new file mode 100644 index 00000000..20cf81e9 --- /dev/null +++ b/static/js/services/contactdata.js @@ -0,0 +1,103 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +define(['underscore', 'jquery'], function(underscore, $) { + + // contactData + return [function() { + + var contacts = {}; + var users = {}; + var count = 0; + + var contactData = { + clear: function(cb) { + _.each(users, _.bind(function(idx, userid) { + var contact = contacts[idx]; + if (cb && contact) { + cb(contact); + } + this.remove(userid); + }, this)); + count = 0; + }, + addByRequest: function(request, status) { + //console.log("addByRequest", request, status); + var userid = request.Userid; + var token = request.Token; + var id; + if (users.hasOwnProperty(userid)) { + // Existing contact. Replace it. + id = users[userid]; + } else { + id = String(count++); + users[userid] = id; + } + var contact = contacts[id] = { + Id: "contact-"+id, + Userid: userid, + Token: token, + Status: null + } + return contact; + }, + addByData: function(data) { + //console.log("addByData", data.Userid, data); + var userid = data.Userid; + if (users.hasOwnProperty(userid)) { + id = users[userid] + } else { + id = String(count++); + users[userid] = id; + } + var contact = contacts[id] = data; + contact.Id = id; + return contact; + }, + get: function(userid) { + if (users.hasOwnProperty(userid)) { + var id = users[userid]; + return contacts[id]; + } + return null; + }, + remove: function(userid) { + if (users.hasOwnProperty(userid)) { + var id = users[userid]; + delete contacts[id]; + } + delete users[userid]; + }, + getById: function(id) { + if (id.indexOf("contact-") === 0) { + id = id.substr(8) + } + if (contacts.hasOwnProperty(id)) { + return contacts[id]; + } + return null + } + }; + + return contactData; + + }]; + +}); diff --git a/static/js/services/contacts.js b/static/js/services/contacts.js new file mode 100644 index 00000000..4f900221 --- /dev/null +++ b/static/js/services/contacts.js @@ -0,0 +1,265 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +define(['underscore', 'jquery', 'modernizr', 'sjcl'], function(underscore, $, Modernizr, sjcl) { + + var Database = function(name) { + this.version = 3; + this.ready = false; + this.db = null; + this.name = name; + this.e = $({}); + var request = indexedDB.open(this.name, this.version); + var that = this; + request.onupgradeneeded = function(event) { + var db = event.target.result; + var transaction = event.target.transaction; + transaction.onerror = _.bind(that.onerror, that); + that.init(db); + console.log("Created contacts database.") + }; + request.onsuccess = _.bind(that.onsuccess, that); + }; + Database.prototype.init = function(db) { + var createOrUpdateStore = function(name, obj) { + if (db.objectStoreNames.contains(name)) { + // TODO(longsleep): Migrate data. + db.deleteObjectStore(name); + } + db.createObjectStore(name, obj); + } + // Create our object stores. + createOrUpdateStore("contacts", { + // We use id field as our unique identifier. + keyPath: "id" + }); + }; + Database.prototype.onerror = function(event) { + console.log("IndexDB database error", event); + }; + Database.prototype.onsuccess = function(event) { + this.db = event.target.result; + this.ready = true; + console.log("Openend database", this.db); + this.e.triggerHandler("ready"); + }; + Database.prototype.put = function(storename, data, successCallback, errorCallback) { + var transaction = this.db.transaction(storename, "readwrite"); + var store = transaction.objectStore(storename); + var request = store.put(data); + if (!errorCallback) { + errorCallback = _.bind(this.onerror, this); + } + transaction.onerror = request.onerror = errorCallback; + if (successCallback) { + request.onsuccess = successCallback; + } + return request; + }; + Database.prototype.delete = function(storename, id, successCallback, errorCallback) { + var transaction = this.db.transaction(storename, "readwrite"); + var store = transaction.objectStore(storename); + var request = store.delete(id); + if (!errorCallback) { + errorCallback = _.bind(this.onerror, this); + } + transaction.onerror = request.onerror = errorCallback; + if (successCallback) { + request.onsuccess = successCallback; + } + return request; + }; + Database.prototype.all = function(storename, iteratorCallback, errorCallback) { + var transaction = this.db.transaction(storename); + var store = transaction.objectStore(storename); + var keyRange = IDBKeyRange.lowerBound(0); + var cursorRequest = store.openCursor(keyRange); + cursorRequest.onsuccess = function(event) { + var result = event.target.result; + if (!result) { + return; + } + //console.log("read data idb", result, event); + iteratorCallback(result.value); + result.continue(); + }; + if (!errorCallback) { + errorCallback = _.bind(this.onerror, this); + } + transaction.onerror = cursorRequest.onerror = errorCallback; + return cursorRequest; + }; + Database.prototype.close = function() { + // TODO(longsleep): Database close. + this.e.off(); + if (this.db) { + this.db.close(); + this.db = null; + this.ready = false; + } + _.defer(_.bind(function() { + this.e.triggerHandler("closed"); + }, this)); + }; + + // contacts + return ["appData", "contactData", "mediaStream", function(appData, contactData, mediaStream) { + + var Contacts = function() { + + this.e = $({}); + this.userid = null; + this.key = null; + this.database = null; + + appData.e.on("authenticationChanged", _.bind(function(event, userid, suserid) { + // TODO(longsleep): Avoid creating empty databases. Create db on store only. + var database = this.open(userid, suserid); + if (database && userid) { + // Load when database is ready and userid is available. + if (database.ready) { + _.defer(_.bind(function() { + if (this.database === database) { + this.load(); + } + }, this)); + } else { + database.e.one("ready", _.bind(function() { + if (this.database === database) { + this.load(); + } + }, this)); + } + } + }, this)); + + }; + + Contacts.prototype.open = function(userid, suserid) { + + if (this.database && (!userid || this.userid !== userid)) { + // Unload existing contacts. + this.unload(); + // Close existing database. + this.database.close(); + this.database = null; + } + if (userid) { + if (!Modernizr.indexeddb) { + return; + } + // Create secure key for hashing and encryption. + this.key = sjcl.codec.base64.fromBits(sjcl.hash.sha256.hash(suserid+mediaStream.config.Token)); + // Create database name for user which. + var id = "mediastream-" + this.id(userid); + console.log("Open of database:", id); + var database = this.database = new Database(id); + return database; + } else { + this.database = null; + return null; + } + + }; + + Contacts.prototype.id = function(userid) { + + var hmac = new sjcl.misc.hmac(this.key); + return sjcl.codec.base64.fromBits(hmac.encrypt(userid)); + + }; + + Contacts.prototype.encrypt = function(data) { + + return sjcl.encrypt(this.key, JSON.stringify(data)); + + }; + + Contacts.prototype.decrypt = function(data) { + + var result; + try { + var s = sjcl.decrypt(this.key, data); + result = JSON.parse(s); + } catch(err) { + console.error("Failed to decrypt contact data", err); + } + return result; + + }; + + Contacts.prototype.load = function() { + if (this.database) { + console.log("Load contacts from storage", this); + var remove = []; + this.database.all("contacts", _.bind(function(data) { + var d = this.decrypt(data.contact); + if (d) { + var contact = contactData.addByData(d); + // TODO(longsleep): Convert buddyImage string to Blob. + this.e.triggerHandler("contactadded", d); + } else { + // Remove empty or invalid entries automatically. + remove.push(data.id); + } + }, this)); + // Remove marked entries. + if (remove.length) { + _.each(remove, _.bind(function(id) { + this.database.delete("contacts", id); + }, this)); + } + } + }; + + Contacts.prototype.unload = function() { + contactData.clear(_.bind(function(contact) { + this.e.triggerHandler("contactremoved", contact); + }, this)); + }; + + Contacts.prototype.add = function(request, status) { + var contact = contactData.addByRequest(request, status); + this.e.triggerHandler("contactadded", contact); + if (this.database) { + this.database.put("contacts", { + id: this.id(contact.Userid), + contact: this.encrypt(contact) + }) + } + }; + + Contacts.prototype.remove = function(userid) { + var contact = contactData.get(userid); + console.log("contacts remove", userid, contact); + if (contact) { + contactData.remove(userid); + if (this.database) { + this.database.delete("contacts", this.id(userid)); + } + this.e.triggerHandler("contactremoved", contact); + } + }; + + return new Contacts(); + + }]; + +}); diff --git a/static/js/services/localstorage.js b/static/js/services/localstorage.js new file mode 100644 index 00000000..dbf2332f --- /dev/null +++ b/static/js/services/localstorage.js @@ -0,0 +1,73 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed WebRTC. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +define(["modernizr"], function(Modernizr) { + + // localStorage + return ["$window", function($window) { + + // PersistentStorage (c)2014 struktur AG. MIT license. + var PersistentStorage = function(prefix) { + this.prefix = prefix ? prefix : "ps"; + this.isPersistentStorage = true; + }; + PersistentStorage.prototype.setItem = function(key, data) { + var name = this.prefix+"_"+key; + $window.document.cookie = name + "=" + data + "; path=/"; + }; + PersistentStorage.prototype.getItem = function(key) { + var name = this.prefix+"_"+key+"="; + var ca = $window.document.cookie.split(';'); + for (var i=0; i -
+
+
{{session.Id|displayName}}
-
{{session.Ua}}
+
({{session.count}}) {{display.subline}}
diff --git a/static/partials/buddyactions.html b/static/partials/buddyactions.html index 6fdb45e8..c3e7d74b 100644 --- a/static/partials/buddyactions.html +++ b/static/partials/buddyactions.html @@ -1,4 +1,11 @@ -
- - + diff --git a/static/partials/buddylist.html b/static/partials/buddylist.html index 6f3b436c..ee1c088a 100644 --- a/static/partials/buddylist.html +++ b/static/partials/buddylist.html @@ -1,4 +1,4 @@ -
+
diff --git a/static/partials/contactrequest.html b/static/partials/contactrequest.html new file mode 100644 index 00000000..cf756d12 --- /dev/null +++ b/static/partials/contactrequest.html @@ -0,0 +1,10 @@ +
+
+
+
+ + +
+
+
+
\ No newline at end of file diff --git a/static/partials/page/welcome.html b/static/partials/page/welcome.html index b5325f63..cf1fd07d 100644 --- a/static/partials/page/welcome.html +++ b/static/partials/page/welcome.html @@ -1,4 +1,4 @@ -
+

{{_("Create your room")}}

{{_("This is your room link:")}}

diff --git a/static/partials/settings.html b/static/partials/settings.html index 8a53772d..6bde5261 100644 --- a/static/partials/settings.html +++ b/static/partials/settings.html @@ -3,7 +3,7 @@

- {{_('Settings')}} + {{_('Profile')}}
@@ -16,65 +16,84 @@
- {{_('Your picture and name are visible to others.')}}
-
- -
-
-
- - - -
-
-
{{userid}}
- {{_('Authenticated by certificate. To log out you have to remove your certificate from the browser.')}} -
-
-
- -
{{userid}}
- -
+
+ +
+
-
- {{_('Only register an ID if this is your private browser.')}} +
+
+
+ {{_('Profile information is public.')}}
-
-
-
- -
- +
+
+ {{_('Account')}} +
+ +
+
+
+ + + +
+
+
{{userid}}
+ {{_('Authenticated by certificate. To log out you have to remove your certificate from the browser.')}} +
+
+
+
+ + +
+
{{userid}}
+ +
+
-
- -
- +
+
+ {{_('Media')}} +
+
+ +
+ +
+
+
+ +
+ +
-
-
- -
-
- - - +
+ +
+
+ + + +
-
+ {{_('Settings')}}