// Copyright 2012 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.collide.client.code;
import com.google.collide.client.bootstrap.BootstrapSession;
import com.google.collide.client.communication.FrontendApi;
import com.google.collide.client.communication.FrontendApi.ApiCallback;
import com.google.collide.client.communication.MessageFilter;
import com.google.collide.client.communication.MessageFilter.MessageRecipient;
import com.google.collide.client.util.QueryCallbacks.SimpleCallback;
import com.google.collide.dto.GetWorkspaceParticipants;
import com.google.collide.dto.GetWorkspaceParticipantsResponse;
import com.google.collide.dto.ParticipantUserDetails;
import com.google.collide.dto.RoutingTypes;
import com.google.collide.dto.ServerError.FailureReason;
import com.google.collide.dto.UserDetails;
import com.google.collide.dto.client.DtoClientImpls.GetWorkspaceParticipantsImpl;
import com.google.collide.json.client.JsoArray;
import com.google.collide.json.client.JsoStringMap;
import com.google.collide.json.client.JsoStringSet;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.json.shared.JsonStringSet;
import com.google.collide.shared.util.JsonCollections;
/**
* Model for the participants in the current workspace.
*/
// TODO: Pass the initial list of participants from the workspace bootstrap response
public class ParticipantModel {
/**
* Listener for changes in the participant model.
*
*/
public interface Listener {
void participantAdded(Participant participant);
void participantRemoved(Participant participant);
}
private static class ColorGenerator {
private static final String[] COLORS = new String[] {"#FC9229", // Orange
"#51D13F", // Green
"#B744D1", // Purple
"#3BC9D1", // Cyan
"#D13B45", // Pinky Red
"#465FE6", // Blue
"#F41BDB", // Magenta
"#B7AC4A", // Mustard
"#723226" // Brown
};
private int previousColorIndex = -1;
private String nextColor() {
previousColorIndex = (previousColorIndex + 1) % COLORS.length;
return COLORS[previousColorIndex];
}
}
public static ParticipantModel create(FrontendApi frontendApi, MessageFilter messageFilter) {
ParticipantModel model = new ParticipantModel(frontendApi);
model.registerForInvalidations(messageFilter);
model.requestAllParticipants();
return model;
}
private final JsoArray<Listener> listeners;
/**
* The last callback created when requesting all participants. If this is null, then the last
* request was for specific participants.
*/
private SimpleCallback<JsonArray<ParticipantUserDetails>> lastRequestAllCallback;
/**
* The set of all active participant ids, including participants who have not been added to the
* participant list because we don't have user details yet.
*/
private JsonStringSet presentParticipantsTracker = JsoStringSet.create();
private final JsoStringMap<Participant> participantsByUserId = JsoStringMap.create();
private final JsoStringMap<String> clientIdToUserId = JsoStringMap.create();
/**
* A map of user ID to the {@link UserDetails} for the participant. We cache participant info in
* case users connect and disconnect rapidly. We keep the cache here so we can discard it when the
* user leaves the workspace.
*/
// TODO: Should we persist user details for the entire session?
private final JsoStringMap<UserDetails> participantUserDetails = JsoStringMap.create();
/**
* Tracks the number of participants (optimization for otherwise having to iterate participants to
* get its size).
*/
private int count;
private final ColorGenerator colorGenerator;
private Participant self;
private final FrontendApi frontendApi;
private ParticipantModel(FrontendApi frontendApi) {
this.frontendApi = frontendApi;
colorGenerator = new ColorGenerator();
listeners = JsoArray.create();
}
public void addListener(Listener listener) {
listeners.add(listener);
}
public int getCount() {
return count;
}
public String getUserId(final String clientId) {
return clientIdToUserId.get(clientId);
}
public Participant getParticipantByUserId(final String id) {
return participantsByUserId.get(id);
}
/**
* Gets the participants keyed by user id. Do not modify the returned map (not enforced for
* performance reasons).
*/
public JsoStringMap<Participant> getParticipants() {
return participantsByUserId;
}
public Participant getSelf() {
return self;
}
private void registerForInvalidations(MessageFilter messageFilter) {
messageFilter.registerMessageRecipient(RoutingTypes.GETWORKSPACEPARTICIPANTSRESPONSE,
new MessageRecipient<GetWorkspaceParticipantsResponse>() {
@Override
public void onMessageReceived(GetWorkspaceParticipantsResponse message) {
handleParticipantUserDetails(message.getParticipants(), true);
}
});
}
public void removeListener(Listener listener) {
listeners.remove(listener);
}
private void createAndAddParticipant(
com.google.collide.dto.Participant participantDto, UserDetails userDetails) {
boolean isSelf =
participantDto.getUserId().equals(BootstrapSession.getBootstrapSession().getUserId());
String color = isSelf ? "black" : colorGenerator.nextColor();
Participant participant = Participant.create(
participantDto, userDetails.getDisplayName(), userDetails.getDisplayEmail(), color, isSelf);
participantsByUserId.put(participantDto.getUserId(), participant);
count++;
if (isSelf) {
self = participant;
}
dispatchParticipantAdded(participant);
}
private void dispatchParticipantAdded(Participant participant) {
for (int i = 0, n = listeners.size(); i < n; i++) {
listeners.get(i).participantAdded(participant);
}
}
private void dispatchParticipantRemoved(Participant participant) {
for (int i = 0, n = listeners.size(); i < n; i++) {
listeners.get(i).participantRemoved(participant);
}
}
/**
* Requests all participants.
*/
private void requestAllParticipants() {
lastRequestAllCallback =
new SimpleCallback<JsonArray<ParticipantUserDetails>>("Failed to retrieve participants") {
@Override
public void onQuerySuccess(JsonArray<ParticipantUserDetails> result) {
/*
* If there is still an outstanding request for all participants, we should replace all
* participants with these results. Even if this request isn't the last request for all
* participants, we should still replace all participants or we might flail in a busy
* workspace and never update the list.
*
* If we've received a tango message containing the updated list of participants,
* lastRequestAllCallback will be null and we should not replace all participants.
*/
boolean replaceAll = (lastRequestAllCallback != null);
/*
* If this is the last callback, then set lastRequestAllCallback to null so older
* lastRequestAllCallbacks received out of order do not overwrite the most recent callback.
*/
if (this == lastRequestAllCallback) {
lastRequestAllCallback = null;
}
handleParticipantUserDetails(result, replaceAll);
}
};
GetWorkspaceParticipants req = GetWorkspaceParticipantsImpl.make();
frontendApi.GET_WORKSPACE_PARTICIPANTS.send(
req, new ApiCallback<GetWorkspaceParticipantsResponse>() {
@Override
public void onMessageReceived(GetWorkspaceParticipantsResponse message) {
lastRequestAllCallback.onQuerySuccess(message.getParticipants());
}
@Override
public void onFail(FailureReason reason) {
// Do nothing.
}
});
}
/**
* Updates the model with the participant user details.
*
* @param isAllParticipants true if the result contains the complete list of participants
*/
private void handleParticipantUserDetails(
JsonArray<ParticipantUserDetails> result, boolean isAllParticipants) {
// Reset the tracker if the result is all inclusive.
if (isAllParticipants) {
presentParticipantsTracker = JsonCollections.createStringSet();
}
for (int i = 0; i < result.size(); i++) {
ParticipantUserDetails item = result.get(i);
UserDetails userDetails = item.getUserDetails();
String userId = userDetails.getUserId();
// Cache the participants' user details.
participantUserDetails.put(userId, userDetails);
clientIdToUserId.put(item.getParticipant().getId(), userId);
if (isAllParticipants) {
presentParticipantsTracker.add(userId);
if (!participantsByUserId.containsKey(userId)) {
createAndAddParticipant(item.getParticipant(), userDetails);
}
} else {
/*
* Add the participant to the list. If the user is not in presentParticipantsTracker set,
* then the participant has since disconnected. If the user is in the participants map, then
* the user was already added to the view.
*/
if (presentParticipantsTracker.contains(userId)
&& !participantsByUserId.containsKey(userId)) {
createAndAddParticipant(item.getParticipant(), userDetails);
}
}
}
// Sweep through participants to find removed participants.
removeOldParticipants();
}
/**
* Removes users who have left the workspace from the participant list.
*/
private void removeOldParticipants() {
// NOTE: Iterating collection that is not affected by removing.
for (String userId : participantsByUserId.getKeys().asIterable()) {
if (!presentParticipantsTracker.contains(userId)) {
Participant participant = participantsByUserId.remove(userId);
if (participant != null) {
count--;
dispatchParticipantRemoved(participant);
}
for (String clientId : clientIdToUserId.getKeys().asIterable()) {
if (clientIdToUserId.get(clientId).equals(userId)) {
clientIdToUserId.remove(clientId);
}
}
}
}
}
}