📜 ⬆️ ⬇️

Free realtime list of online users (Parse.com + Pubnub)

Once I wrote a familiar task for practice: write an application where there is one login / login button and an online user list. At the same time, users must "live" only 30 seconds. As is always the case, during the initial consideration of the problem, I thought: ha, what is there to do then? We use cloud storage and a server for users, and then it’s a bit small ... but it’s not there.

Under the cut, I'll tell you what problems I had to face when developing the backend on Parse.com, why I had to use it in conjunction with Pubnub, and how to connect all this when developing for Android.

What happened in the end:
')
Demonstration


Strictly speaking, the topic of bundles Parse.com and Pubnub have already been engaged in Habré . However, unlike the article, here I want to stop in more detail on the cloud code Parse.com, and the target application is developing for Android, and not iOS.

Parse.com


Parse.com provides extensive cloud functionality: here is the database in a beautiful graphical wrapper, and server code, and analytics, and even push notifications! And everything is free until you cross the threshold of 30 requests per second, 20GB of used storage capacity, etc. . I was completely satisfied with these requirements (it's free!), So the choice fell on this service.

Problems

Having thoroughly studied the guide , several problems surfaced, connected with the real-time list of online users and their lifetime :
  1. no fields of type Timer for custom objects
  2. the service does not provide the possibility of a long pulling (or I did not find one like this)
  3. Standard class User / Session is not suitable for this task.

Solutions

Let me explain why this is exactly the problem.
The Timer type (or something like that) was planned to be used as an expireAt field in order to receive notifications (ideally, automatically delete) about when the user “dies”.

In the absence of this type, you will have to use the usual Date type and watch yourself when the user needs to be “killed”.

It was planned to use long pulling to track incoming / outgoing users, but the service does not provide such an option out of the box.

It was decided to use installations . In short, these are global channels for transferring data between anyone (server-client, client-client, etc.). Thus, after the login / login the server should send a message to the channel that such and such a user is logged in / out. This provides a real-time user list. (However, this is not entirely true, but more on that later).

The Parse.com SDK has built-in login / login methods, so it would be very nice to use them when developing. However, this proved impossible.

As mentioned above, when logging in / logging out, a message should be sent to the channel about whether the user is “alive” (or “dead” if he has logged out). The service provides the ability to create triggers, for example, AfterSave, beforeDelete, etc. The problem is that there are no such events for Session. This means that for each file you need to literally delete the user with his session, which negates all the advantages of the methods built into the SDK.

Therefore, it was decided to use the IMH_Session custom class, by hooking it with the afterDelete and afterSave triggers, in which an alert is sent to the global channel.

Nuances

And then it would be time to celebrate and sit down victoriously at Android Studio, but ... installations are based on default push notifications. Let me explain for those who, like me, in the tank. Push notifications do not guarantee anything. They work on the principle of Fire & Forget, that is, by sending a push notification, there is no certainty that it reached the addressee. Moreover, it is impossible to even say when it happened!

So there’s no real-time talk.

There is also a problem with flooding in the channel. Installations are equal, so any client can send anything, and the rest will have to choose from this garbage only server messages. And it's not a fact that they are from the server. There is a problem of verification. At a minimum, for each message to the server, you will have to send a request to confirm information about the user, which will lead to chaos and those same free 30 requests per second will quickly end.

Pubnub


Despite the deplorable situation, a solution was found. This output was Pubnub . In general, this service out of the box provides the SDK for online chat, but, unfortunately, it is paid and is called an addon .

The service itself provides everything you need for real-time applications. But we are only interested in one thing - realtime broadcast channels. They are free, easy to use and, perhaps most importantly, they have access control! Just what we need to not bother with verification.

The delimitation occurs due to two separate keys: publish_key and subscribe_key. As you probably already guessed, the first key goes to the server, and the second to the client application. If you keep the key secret first, no one will spam the channel and you can trust any message specified in it. Perfect!

ps I’m writing Parse.com, but Pubnub (without `.com`) is just because I’m used to it. Hope this doesn't hurt anyone's eyes.

Backend - Parse.com


Now it was necessary to start organizing the server API and its implementation. Let me remind you that the idea of ​​the pipeline is:
  1. custom (via API) login ()
  2. Parse.com cloud code
  3. creation of the user in a DB
  4. trigger afterSave (), notifying Pubnub channel about user login
  5. return current user in response to login ()

The following APIs have been created for this:

I will explain about the latter. I planned to use it to synchronize client time with server time. I implemented the API myself, but I didn’t make it in my hands. However, the API itself, I decided to leave.

I apologize in advance for the smell of the code. Never a js developer. I will consider any wishes on his organization:
Server code
/*global Parse:false, $:false, jQuery:false */ // Importas var _ = require('underscore'); // jshint ignore:line var moment = require('moment'); // jshint ignore:line // Constants var sessionObjName = "IMH_Session"; var sessionLifetimeSec = 13; var channelName = "events"; var publishKey = "pub-c-6271f363-519a-432d-9059-e65a7203ce0e", subscribeKey = "sub-c-a3d06db8-410b-11e5-8bf2-0619f8945a4f", httpRequestUrl = 'http://pubsub.pubnub.com/publish/' + publishKey + '/' + subscribeKey + '/0/' + channelName + '/0/'; // Utils function Log(obj, tag) { "use strict"; var loggingString = "Cloud_code: "; if (tag != null) { // jshint ignore:line loggingString += "[" + tag + "] "; } loggingString += JSON.stringify(obj) + "\n"; console.log(loggingString); // jshint ignore:line } function GetNow() { "use strict"; return moment.utc(); } // Supporting var baseSession = {udid: "", loginedAt: GetNow(), aliveTo: GetNow()}; var errorHandler = function(error) { "use strict"; Log(error.message, "error"); }; function DeleteSession(obj) { obj.set("loginedAt", obj.get("aliveTo")); SendEvent(obj); obj.destroy(); } function DeleteDeadSessions() { "use strict"; var query = new Parse.Query(sessionObjName); // jshint ignore:line var promise = query.lessThanOrEqualTo("aliveTo", GetNow().toDate()) .each(function(obj) { Log(obj, "Delete dead session"); DeleteSession(obj); } ); return promise; } function NewSession(udid) { "use strict"; var session = _.clone(baseSession); session.udid = udid; session.loginedAt = GetNow(); session.aliveTo = GetNow().add({seconds: sessionLifetimeSec}); return session; } function GetSessionQuery() { "use strict"; var objConstructor = Parse.Object.extend(sessionObjName); // jshint ignore:line var query = new Parse.Query(objConstructor); //query.select("udid", "loginedAt", "aliveTo"); //not work for some reason return query; } function IsUserOnline(udid, onUserOnlineHanlder, onUserOfflineHanlder, onError) { "use strict"; var userAlive = false; var query = GetSessionQuery(); query.equalTo("udid", udid).greaterThanOrEqualTo("aliveTo", GetNow().toDate()); query.find({ success: function(result) { if (result.length == 0) { onUserOfflineHanlder(); } else { onUserOnlineHanlder(result); } }, error: onError }); } function NewParseSession(session) { "use strict"; var objConstructor = Parse.Object.extend(sessionObjName); // jshint ignore:line var obj = new objConstructor(); obj.set({ udid: session.udid, loginedAt: session.loginedAt.toDate(), aliveTo: session.aliveTo.toDate() } ); return obj; } function SendEvent(session) { "use strict"; Parse.Cloud.httpRequest({ // jshint ignore:line url: httpRequestUrl + JSON.stringify(session), success: function(httpResponse) {}, error: function(httpResponse) { Log('Request failed with response code ' + httpResponse.status); } }); } // API functions var API_GetNow = function(request, response) { "use strict"; var onUserOnline = function(result) { response.success( GetNow().toDate() ); }; var onUserOffline = function(error) { response.error(error); }; var onError = function(error) { response.error(error); }; IsUserOnline(request.params.udid, onUserOnline, onUserOffline, onError); }; var API_GetOnlineUsers = function(request, response) { "use strict"; var onUserOnline = function(result) { var query = GetSessionQuery() .addDescending("aliveTo"); query.find({ success: function(result) { response.success( JSON.stringify(result) ); }, error: errorHandler }); }; var onUserOffline = function(error) { response.error(error); }; var onError = function(error) { response.error(error); }; DeleteDeadSessions().always( function() { IsUserOnline(request.params.udid, onUserOnline, onUserOffline, onError); }); }; var API_Login = function(request, response) { "use strict"; var userUdid = request.params.udid; var session = NewSession(userUdid); var parseObject = NewParseSession(session); Parse.Cloud.run("Logout", {udid: userUdid}).always( function() { parseObject.save(null, { success: function(obj) { Log(obj, "Login:save"); response.success( JSON.stringify(parseObject) ); }, error: function(error) { errorHandler(error); response.error(error); } }); }); }; var API_Logout = function(request, response) { "use strict"; var userUdid = request.params.udid; var query = GetSessionQuery() .equalTo("udid", userUdid); query.each( function(obj) { Log(obj, "Logout:destroy"); DeleteSession(obj); }).done( function() {response.success();} ); }; // Bindings Parse.Cloud.afterSave(sessionObjName, function(request) { // jshint ignore:line "use strict"; SendEvent(request.object); }); // API definitions Parse.Cloud.define("GetNow", API_GetNow); // jshint ignore:line Parse.Cloud.define("GetOnlineUsers", API_GetOnlineUsers); // jshint ignore:line Parse.Cloud.define("Login", API_Login); // jshint ignore:line Parse.Cloud.define("Logout", API_Logout); // jshint ignore:line 


As you can see, I did without the afterDelete () trigger. The reason is that with afterDelete () I had races. On the one hand, the newly released user is now deleted and will soon send an alert to the channel. On the other hand, he immediately tries to log in again.
In the end, the channel will see something like “X went in,” “X went in,” “X went out.” The last two messages are not in their places. Because of this, there have been situations on the client when the user seems to be still “alive” and has just entered, but is not displayed in the online list, because if you believe the channel, then it is “dead.”

More nuances!

As noted earlier, Parse.com makes it necessary to use Date, instead of any Timer, to organize expireAt (in our case, aliveTo). But the question is: when will they check all users on whether they are “alive” or already “dead”?

One solution is to use Job and delete inactive users every 5-10 seconds. But strictly speaking, this is not quite realtime. I wanted users to "die" instantly, regardless of some kind of background-job (by the way, it has a limit on the maximum execution time - 15 minutes. So it would have to be recreated all the time). Therefore, a different approach was implemented.

How does the ordinary life of the user:

Login -> GetOnlineUsers -> Logout

or

Login -> GetOnlineUsers -> minimized the application, that is, skipped messages in the channel -> GetOnlineUsers -> Logout

It was decided to remove the “dead” users at the moment when someone requests GetOnlineUsers. This means that, in fact, “dead” users can be stored in the database for at least as long as someone requests a list of “live”. At this moment, all dead users will be removed (in the best traditions of lazy computing).

Thus, the "life" of users will have to follow locally on the client. Alert in the channel about the death of the user will come only if he logged out himself. Otherwise, the user is considered alive forever.

Android


Pubnub

Pubnub SDK, or rather its free part, is very easy to use. To begin with, a wrapper was made over Pubnub so that, if anything, you could use any other service:

Wrap over Pubnub - Channel
 public class PubnubChannel extends Channel { static private final String CHANNEL_NAME = "events"; static private final String SUBSCRIBE_KEY = "sub-c-a3d06db8-410b-11e5-8bf2-0619f8945a4f"; Pubnub pubnub = new Pubnub("", SUBSCRIBE_KEY); Callback pubnubCallback = new Callback() { @Override public void connectCallback(String channel, Object message) { if (listener != null) { listener.onConnect(channel, "Connected: " + message.toString()); } } @Override public void disconnectCallback(String channel, Object message) { if (listener != null) { listener.onDisconnect(channel, "Disconnected: " + message.toString()); } } @Override public void reconnectCallback(String channel, Object message) { if (listener != null) { listener.onReconnect(channel, "Reconnected: " + message.toString()); } } @Override public void successCallback(String channel, Object message, String timetoken) { if (listener != null) { listener.onMessageRecieve(channel, message.toString(), timetoken); } } @Override public void errorCallback(String channel, PubnubError error) { if (listener != null) { listener.onErrorOccur(channel, "Error occured: " + error.toString()); } } }; public PubnubChannel() { setName(CHANNEL_NAME); } @Override public void subscribe() throws ChannelException { try { pubnub.subscribe(CHANNEL_NAME, pubnubCallback); } catch (PubnubException e) { e.printStackTrace(); throw new ChannelException(ChannelException.CONNECT_ERROR, e); } } @Override public void unsubscribe() { pubnub.unsubscribeAll(); } } 


Then a wrapper was made over the obstetric (yes-yes), in order to track not some messages in the channel, but counter users:

Wrap on Channel - ServerChannel
 public class ServerChannel { Logger l = LoggerFactory.getLogger(ServerChannel.class); JsonParser jsonParser; Channel serverChannel; ServerChannel.EventListener listener; private final Channel.EventListener listenerAdapter = new Channel.EventListener() { @Override public void onConnect(String channel, String greeting) { } @Override public void onDisconnect(String channel, String reason) { if (listener != null) { listener.onDisconnect(reason); } } @Override public void onReconnect(String channel, String reason) { } @Override public void onMessageRecieve(String channel, String message, String timetoken) { if (listener != null) { ServerChannel.this.onMessageRecieve(message, timetoken); } } @Override public void onErrorOccur(String channel, String error) { l.warn(String.format("%s : [error] %s", channel, error)); if (listener != null) { ServerChannel.this.unsubscribe(); } } }; public ServerChannel(Channel serverChannel, JsonParser jsonParser) { this.serverChannel = serverChannel; this.jsonParser = jsonParser; } public final void setListener(@NonNull ServerChannel.EventListener listener) { this.listener = listener; } public final void clearListener() { listener = null; } public final void subscribe() throws ChannelException { try { serverChannel.setListener(listenerAdapter); serverChannel.subscribe(); } catch (ChannelException e) { e.printStackTrace(); serverChannel.clearListener(); throw e; } } public final void unsubscribe() { serverChannel.unsubscribe(); serverChannel.clearListener(); } public void onMessageRecieve(String userJson, String timetoken) { DyingUser dyingUser = jsonParser.fromJson(userJson, DyingUser.class); if (dyingUser != null) { if (dyingUser.isAlive()) { listener.onUserLogin(dyingUser); } else { listener.onUserLogout(dyingUser); } } } public interface EventListener { void onDisconnect(String reason); void onUserLogin(DyingUser dyingUser); void onUserLogout(DyingUser dyingUser); } } 


Parse.com

Again, nothing complicated. All logic is stored on the server. All we need is to use the API and parse json into the objects.

AuthApi
 public class AuthApi extends Api { static final String API_Login = "Login", API_Logout = "Logout"; @Inject public AuthApi(JsonParser parser) { super(parser); } public DyingUser login(@NonNull final String udid) throws ApiException { DyingUser dyingUser; try { String jsonObject = ParseCloud.callFunction(API_Login, constructRequestForUser(udid)); dyingUser = parser.fromJson(jsonObject, DyingUser.class); } catch (ParseException e) { e.printStackTrace(); throw new ApiException(ApiException.LOGIN_ERROR, e); } return dyingUser; } public void logout(@NonNull final DyingUser dyingUser) { try { ParseCloud.callFunction(API_Logout, constructRequestForUser(dyingUser.getUdid())); } catch (ParseException e) { e.printStackTrace(); } } } 


UserApi
 public class UserApi extends Api { static final String API_GetOnlineUsers = "GetOnlineUsers"; @Inject public UserApi(JsonParser parser) { super(parser); } public final ArrayList<DyingUser> getOnlineUsers(@NonNull final DyingUser dyingUser) throws ApiException { ArrayList<DyingUser> users; try { String jsonUsers = ParseCloud.callFunction(API_GetOnlineUsers, constructRequestForUser(dyingUser.getUdid())); users = parser.fromJson(jsonUsers, new TypeToken<List<DyingUser>>(){}.getType()); } catch (ParseException e) { e.printStackTrace(); throw new ApiException(ApiException.GET_USERS_ERROR, e); } return users; } } 


Well, the base class:

Api
 abstract class Api { final JsonParser parser; Api(JsonParser parser) { this.parser = parser; } protected Map<String, ?> constructRequestForUser(@NonNull final String udid) { Map<String, String> result = new HashMap<>(); result.put("udid", udid); return result; } } 


Using the above classes and their methods, we get access to the login, login and get online user list.

Realtime

UI update

Since the users "die" and rather quickly, it was decided to display their remaining time of life. Since the lifetime is measured in seconds and the goal of the task in providing real-time, the UI should be updated at least once a second. For this, the TimeTicker class has been made, whose object is stored in the Activity. Activity fragments during onAttach () receive a TimeTicker object from the Activity () (for this is the TimeTicker.Owner interface) and subscribe to its events.

Timeticker
 public class TimeTicker extends Listenable<TimeTicker.EventListener> { private static final long TICKING_PERIOD_MS_DEFAULT = 1000; private static final boolean DO_INSTANT_TICK_ON_START_DEFAULT = true; long tickingPeriodMs; boolean doInstantTickOnStart; final Handler uiHandler = new Handler(Looper.getMainLooper()); final Timer tickingTimer = new Timer(); TimerTask tickingTask; public TimeTicker() { this(DO_INSTANT_TICK_ON_START_DEFAULT); } public TimeTicker(boolean doInstantTickOnStart) { this.doInstantTickOnStart = doInstantTickOnStart; setTickingPeriodMs(TICKING_PERIOD_MS_DEFAULT); } public void setTickingPeriodMs(final long tickingPeriodMs) { this.tickingPeriodMs = tickingPeriodMs; } public synchronized void start() { if (tickingTask != null) { stop(); } tickingTask = new TimerTask() { @Override public void run() { uiHandler.post(new Runnable() { @Override public void run() { forEachListener(new ListenerExecutor<TimeTicker.EventListener>() { @Override public void run() { getListener().onSecondTick(); } }); } }); } }; long delay = (doInstantTickOnStart) ? 0 : tickingPeriodMs; tickingTimer.scheduleAtFixedRate(tickingTask, delay, tickingPeriodMs); } public synchronized void stop() { if (tickingTask != null) { tickingTask.cancel(); } tickingTask = null; tickingTimer.purge(); } public interface EventListener extends Listenable.EventListener { void onSecondTick(); } public interface Owner { TimeTicker getTimeTicker(); } } 


This ensures that the UI is updated once a second, which means that everything looks as if users are gradually dying.

List of "dying" users

This problem seemed to me the most interesting of all related to this task: we have a list of users who "die." Their time is close to zero, and when this happens, the user should be removed from the list.

The simplest implementation is to tie the timer to each user and delete it when reaching "death". However, this is not a particularly interesting decision. Let's pervert! This is the implementation I got with the use of one timer and the possibility of pause / resume (if the application is minimized, for example, it comes in handy).

I have never refactored this code since I wrote it for the first time, so it may not be very good:

TemporarySet
 public class TemporarySet<TItem> extends Listenable<TemporarySet.EventListener> implements Resumable { protected final SortedSet<TemporaryElement<TItem>> sortedElementsSet = new TreeSet<>(); protected final List<TItem> list = new ArrayList<>(); protected final Timer timer = new Timer(); protected TimerTask timerTask = null; protected TemporaryElement<TItem> nextElementToDie = null; boolean isResumed = false; public TemporarySet() { notifier = new TemporarySet.EventListener() { @Override public void onCleared() { for (TemporarySet.EventListener listener : getListenersSet()) { listener.onCleared(); } } @Override public void onAdded(Object item) { for (TemporarySet.EventListener listener : getListenersSet()) { listener.onAdded(item); } } @Override public void onRemoved(Object item) { for (TemporarySet.EventListener listener : getListenersSet()) { listener.onRemoved(item); } } }; } public boolean add(TItem object, DateTime deathTime) { TemporaryElement<TItem> element = new TemporaryElement<>(object, deathTime); return _add(element); } public boolean remove(TItem object) { TemporaryElement<TItem> element = new TemporaryElement<>(object); return _remove(element); } public void clear() { _clear(); } public final List<TItem> asReadonlyList() { return Collections.unmodifiableList(list); } private synchronized void _clear() { cancelNextDeath(); list.clear(); sortedElementsSet.clear(); notifier.onCleared(); } private synchronized boolean _add(TemporaryElement<TItem> insertingElement) { boolean wasInserted = _insertElementUnique(insertingElement); if (wasInserted) { if (nextElementToDie != null && nextElementToDie.deathTime.isAfter(insertingElement.deathTime)) { cancelNextDeath(); } if (nextElementToDie == null) { openNextDeath(); } notifier.onAdded(insertingElement.object); } return wasInserted; } private synchronized boolean _remove(TemporaryElement<TItem> deletingElement) { boolean wasDeleted = _deleteElementByObject(deletingElement); if (wasDeleted) { if (nextElementToDie.equals(deletingElement)) { cancelNextDeath(); openNextDeath(); } notifier.onRemoved(deletingElement.object); } return wasDeleted; } private synchronized void openNextDeath() { cancelNextDeath(); if (sortedElementsSet.size() != 0) { nextElementToDie = sortedElementsSet.first(); timerTask = new TimerTask() { @Override public void run() { _remove(nextElementToDie); } }; DateTime now = new DateTime(); Duration duration = TimeUtils.GetNonNegativeDuration(now, nextElementToDie.deathTime); timer.schedule(timerTask, duration.getMillis()); } } private synchronized void cancelNextDeath() { if (timerTask != null) { timerTask.cancel(); } timer.purge(); nextElementToDie = null; timerTask = null; } private synchronized Iterator<TemporaryElement<TItem>> findElement(TemporaryElement<TItem> searchingElement) { Iterator<TemporaryElement<TItem>> resultIterator = null; for (Iterator<TemporaryElement<TItem>> iterator = sortedElementsSet.iterator(); iterator.hasNext() && resultIterator == null;) { if (iterator.next().equals(searchingElement)) { resultIterator = iterator; } } return resultIterator; } private synchronized boolean _insertElementUnique(TemporaryElement<TItem> element) { boolean wasInserted = false; Iterator<TemporaryElement<TItem>> iterator = findElement(element); if (iterator == null) { wasInserted = true; sortedElementsSet.add(element); list.add(element.object); } return wasInserted; } private synchronized boolean _deleteElementByObject(TemporaryElement<TItem> element) { boolean wasDeleted = false; Iterator<TemporaryElement<TItem>> iterator = findElement(element); if (iterator != null) { wasDeleted = true; iterator.remove(); list.remove(element.object); } return wasDeleted; } @Override public void resume() { isResumed = true; openNextDeath(); } @Override public void pause() { cancelNextDeath(); isResumed = false; } @Override public boolean isResumed() { return isResumed; } public interface EventListener extends Listenable.EventListener { void onCleared(); void onAdded(Object item); void onRemoved(Object item); } } 


I want to note that there is an asReadonlyList method that I do not use. Previously, it was used as an Adapter argument for a ListFragment, which made it possible not to use any EventListener at all. But later I decided to move away from this undertaking, but the code decided to leave (for the future, to see how you should not do it).

The largest orgy in this list is created in the findElement, _insertElementUnique and _deleteElementByObject methods. The reason is that SortedSet stores objects sorted by date and, accordingly, the search also takes place by date. However, when the user "dies", the server sends a message in which loginedAt == deathAt, which leads to the madness of SortedSet and the entire TemporarySet.

Since there are no normal Pair <A, B> in Java ( upd: as Bringoff rightly pointed out, it is still there ), the wrapper was implemented:

TemporaryElement
 class TemporaryElement<T> implements Comparable { protected final T object; protected final DateTime deathTime; public TemporaryElement(@NonNull T object, @NonNull DateTime deathTime) { this.deathTime = deathTime; this.object = object; } public TemporaryElement(@NonNull T object) { this(object, new DateTime(0)); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; TemporaryElement<?> that = (TemporaryElement<?>) o; return object.equals(that.object); } @Override public int hashCode() { return object.hashCode(); } @Override public int compareTo(@NonNull Object another) { TemporaryElement a = this, b = (TemporaryElement) another; int datesComparisionResult = a.deathTime.compareTo(b.deathTime); int objectsComparisionResult = a.hashCode() - b.hashCode(); return (datesComparisionResult != 0) ? datesComparisionResult : objectsComparisionResult; } } 


As a result, the implemented TemporarySet allows you to add / delete users with a lifetime, after which it remains only to implement the TemporarySet.EventListener interface and wait.

Conclusion


The task turned out to be more difficult than originally planned. I spent a lot of time parsing the Parse.com Guide. Here, for example, one of the nuances:

afterSave
 Parse.Cloud.afterSave("Foo", function(request) {}); // custom Foo object Parse.Cloud.afterSave("User", function(request) {}); // custom(!) User object Parse.Cloud.afterSave(Parse.User, function(request) {}); // Parse.com User object Parse.Cloud.afterSave(Parse.Session, function(request) {}); // error! can't bind to Parse.Session 


Much time was spent on gradient animation. More precisely, not so much on the animation, as on the search for a ready-made solution. Unfortunately, I did not find a suitable method for me, so I wrote my solution. I wrote in detail on stackoverflow in broken English .

All my code can be viewed here .

To be fair, I would like to note that it would be nice to add something like GetUsersChangesAfterDate () to the API, which would allow you to receive changes in the list of users after the specified date (I mean, turned off the application -> deployed -> GetUsersChangesAfterDate).

And at the end I would like to ask a few questions to the reader:
  1. Could it have been easier, but also free?
  2. Is there an easier way to update a UI every N seconds?
  3. What to do with the time of life "0: 0" the user? Should we artificially add 1 second to the time of life, so that the user “dies” after “0: 1”? Or is it decided somehow differently? Or leave "0: 0" - is this normal?

Source: https://habr.com/ru/post/266617/


All Articles