svelte-firekit 0.0.25 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +445 -213
- package/dist/components/Collection.svelte +150 -0
- package/dist/components/Collection.svelte.d.ts +27 -0
- package/dist/components/Ddoc.svelte +131 -0
- package/dist/components/Ddoc.svelte.d.ts +28 -0
- package/dist/components/Node.svelte +97 -0
- package/dist/components/Node.svelte.d.ts +23 -0
- package/dist/components/auth-guard.svelte +89 -0
- package/dist/components/auth-guard.svelte.d.ts +26 -0
- package/dist/components/custom-guard.svelte +122 -0
- package/dist/components/custom-guard.svelte.d.ts +31 -0
- package/dist/components/download-url.svelte +92 -0
- package/dist/components/download-url.svelte.d.ts +19 -0
- package/dist/components/firebase-app.svelte +30 -0
- package/dist/components/firebase-app.svelte.d.ts +7 -0
- package/dist/components/node-list.svelte +102 -0
- package/dist/components/node-list.svelte.d.ts +27 -0
- package/dist/components/signed-in.svelte +42 -0
- package/dist/components/signed-in.svelte.d.ts +11 -0
- package/dist/components/signed-out.svelte +42 -0
- package/dist/components/signed-out.svelte.d.ts +11 -0
- package/dist/components/storage-list.svelte +97 -0
- package/dist/components/storage-list.svelte.d.ts +26 -0
- package/dist/components/upload-task.svelte +108 -0
- package/dist/components/upload-task.svelte.d.ts +24 -0
- package/dist/config.js +17 -39
- package/dist/firebase.d.ts +43 -21
- package/dist/firebase.js +121 -35
- package/dist/index.d.ts +21 -13
- package/dist/index.js +27 -15
- package/dist/services/auth.d.ts +397 -0
- package/dist/services/auth.js +882 -0
- package/dist/services/collection.svelte.d.ts +286 -0
- package/dist/services/collection.svelte.js +871 -0
- package/dist/services/document.svelte.d.ts +288 -0
- package/dist/services/document.svelte.js +555 -0
- package/dist/services/mutations.d.ts +336 -0
- package/dist/services/mutations.js +1079 -0
- package/dist/services/presence.svelte.d.ts +141 -0
- package/dist/services/presence.svelte.js +727 -0
- package/dist/{realtime → services}/realtime.svelte.d.ts +3 -1
- package/dist/{realtime → services}/realtime.svelte.js +13 -7
- package/dist/services/storage.svelte.d.ts +257 -0
- package/dist/services/storage.svelte.js +374 -0
- package/dist/services/user.svelte.d.ts +296 -0
- package/dist/services/user.svelte.js +609 -0
- package/dist/types/auth.d.ts +158 -0
- package/dist/types/auth.js +106 -0
- package/dist/types/collection.d.ts +360 -0
- package/dist/types/collection.js +167 -0
- package/dist/types/document.d.ts +342 -0
- package/dist/types/document.js +148 -0
- package/dist/types/firebase.d.ts +44 -0
- package/dist/types/firebase.js +33 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.js +4 -0
- package/dist/types/mutations.d.ts +387 -0
- package/dist/types/mutations.js +205 -0
- package/dist/types/presence.d.ts +282 -0
- package/dist/types/presence.js +80 -0
- package/dist/utils/errors.d.ts +21 -0
- package/dist/utils/errors.js +35 -0
- package/dist/utils/firestore.d.ts +9 -0
- package/dist/utils/firestore.js +33 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.js +8 -0
- package/dist/utils/providers.d.ts +16 -0
- package/dist/utils/providers.js +30 -0
- package/dist/utils/user.d.ts +8 -0
- package/dist/utils/user.js +29 -0
- package/package.json +64 -64
- package/dist/auth/auth.d.ts +0 -117
- package/dist/auth/auth.js +0 -194
- package/dist/auth/presence.svelte.d.ts +0 -139
- package/dist/auth/presence.svelte.js +0 -373
- package/dist/auth/user.svelte.d.ts +0 -112
- package/dist/auth/user.svelte.js +0 -155
- package/dist/firestore/awaitable-doc.svelte.d.ts +0 -141
- package/dist/firestore/awaitable-doc.svelte.js +0 -183
- package/dist/firestore/batch-mutations.svelte.d.ts +0 -140
- package/dist/firestore/batch-mutations.svelte.js +0 -218
- package/dist/firestore/collection-group.svelte.d.ts +0 -78
- package/dist/firestore/collection-group.svelte.js +0 -120
- package/dist/firestore/collection.svelte.d.ts +0 -96
- package/dist/firestore/collection.svelte.js +0 -137
- package/dist/firestore/doc.svelte.d.ts +0 -90
- package/dist/firestore/doc.svelte.js +0 -131
- package/dist/firestore/document-mutations.svelte.d.ts +0 -164
- package/dist/firestore/document-mutations.svelte.js +0 -273
- package/dist/storage/download-url.svelte.d.ts +0 -83
- package/dist/storage/download-url.svelte.js +0 -114
- package/dist/storage/storage-list.svelte.d.ts +0 -89
- package/dist/storage/storage-list.svelte.js +0 -123
- package/dist/storage/upload-task.svelte.d.ts +0 -94
- package/dist/storage/upload-task.svelte.js +0 -138
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview FirekitPresence - Clean presence tracking for Svelte applications
|
|
3
|
+
* @module FirekitPresence
|
|
4
|
+
* @version 1.0.0
|
|
5
|
+
*/
|
|
6
|
+
import { ref, onValue, onDisconnect, set, get, serverTimestamp } from 'firebase/database';
|
|
7
|
+
import { firebaseService } from '../firebase.js';
|
|
8
|
+
import { browser } from '$app/environment';
|
|
9
|
+
import { PresenceErrorCode, PresenceError } from '../types/presence.js';
|
|
10
|
+
/**
|
|
11
|
+
* Handles geolocation tracking
|
|
12
|
+
*/
|
|
13
|
+
class GeolocationService {
|
|
14
|
+
config;
|
|
15
|
+
watchId = null;
|
|
16
|
+
_hasConsent = $state(false);
|
|
17
|
+
_location = $state(null);
|
|
18
|
+
_error = $state(null);
|
|
19
|
+
constructor(config) {
|
|
20
|
+
this.config = config;
|
|
21
|
+
}
|
|
22
|
+
get hasConsent() {
|
|
23
|
+
return this._hasConsent;
|
|
24
|
+
}
|
|
25
|
+
get location() {
|
|
26
|
+
return this._location;
|
|
27
|
+
}
|
|
28
|
+
get error() {
|
|
29
|
+
return this._error;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Request user consent for location tracking
|
|
33
|
+
*/
|
|
34
|
+
async requestConsent() {
|
|
35
|
+
if (!this.config.enabled || !browser)
|
|
36
|
+
return false;
|
|
37
|
+
try {
|
|
38
|
+
if (this.config.type === 'browser') {
|
|
39
|
+
const success = await new Promise((resolve) => {
|
|
40
|
+
navigator.geolocation.getCurrentPosition(() => resolve(true), () => resolve(false), {
|
|
41
|
+
timeout: this.config.timeout || 10000,
|
|
42
|
+
enableHighAccuracy: this.config.enableHighAccuracy || false
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
this._hasConsent = success;
|
|
46
|
+
return success;
|
|
47
|
+
}
|
|
48
|
+
this._hasConsent = true;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
this._error = error;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Get current location
|
|
58
|
+
*/
|
|
59
|
+
async getCurrentLocation() {
|
|
60
|
+
if (!this.config.enabled || (this.config.requireConsent && !this._hasConsent)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
switch (this.config.type) {
|
|
65
|
+
case 'browser':
|
|
66
|
+
return this.getBrowserLocation();
|
|
67
|
+
case 'ip':
|
|
68
|
+
return this.getIPLocation();
|
|
69
|
+
case 'custom':
|
|
70
|
+
return this.getCustomLocation();
|
|
71
|
+
default:
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
this._error = error;
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Start watching location changes
|
|
82
|
+
*/
|
|
83
|
+
startWatching(updateInterval) {
|
|
84
|
+
if (!this.config.enabled || this.watchId)
|
|
85
|
+
return;
|
|
86
|
+
const watchLocation = async () => {
|
|
87
|
+
const location = await this.getCurrentLocation();
|
|
88
|
+
if (location) {
|
|
89
|
+
this._location = location;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
watchLocation();
|
|
93
|
+
this.watchId = window.setInterval(watchLocation, updateInterval);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Stop watching location changes
|
|
97
|
+
*/
|
|
98
|
+
stopWatching() {
|
|
99
|
+
if (this.watchId) {
|
|
100
|
+
clearInterval(this.watchId);
|
|
101
|
+
this.watchId = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async getBrowserLocation() {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
navigator.geolocation.getCurrentPosition((position) => {
|
|
107
|
+
resolve({
|
|
108
|
+
latitude: position.coords.latitude,
|
|
109
|
+
longitude: position.coords.longitude,
|
|
110
|
+
accuracy: position.coords.accuracy,
|
|
111
|
+
altitude: position.coords.altitude || undefined,
|
|
112
|
+
altitudeAccuracy: position.coords.altitudeAccuracy || undefined,
|
|
113
|
+
heading: position.coords.heading || undefined,
|
|
114
|
+
speed: position.coords.speed || undefined,
|
|
115
|
+
lastUpdated: new Date().toISOString(),
|
|
116
|
+
source: 'browser'
|
|
117
|
+
});
|
|
118
|
+
}, () => resolve(null), {
|
|
119
|
+
timeout: this.config.timeout || 10000,
|
|
120
|
+
enableHighAccuracy: this.config.enableHighAccuracy || false,
|
|
121
|
+
maximumAge: this.config.maximumAge || 300000
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async getIPLocation() {
|
|
126
|
+
if (!this.config.ipServiceUrl)
|
|
127
|
+
return null;
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetch(this.config.ipServiceUrl);
|
|
130
|
+
const data = await response.json();
|
|
131
|
+
return {
|
|
132
|
+
latitude: data.latitude,
|
|
133
|
+
longitude: data.longitude,
|
|
134
|
+
accuracy: data.accuracy || undefined,
|
|
135
|
+
lastUpdated: new Date().toISOString(),
|
|
136
|
+
source: 'ip'
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async getCustomLocation() {
|
|
144
|
+
if (!this.config.customGeolocationFn)
|
|
145
|
+
return null;
|
|
146
|
+
try {
|
|
147
|
+
const result = await this.config.customGeolocationFn();
|
|
148
|
+
return {
|
|
149
|
+
...result,
|
|
150
|
+
lastUpdated: new Date().toISOString(),
|
|
151
|
+
source: 'custom'
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
dispose() {
|
|
159
|
+
this.stopWatching();
|
|
160
|
+
this._hasConsent = false;
|
|
161
|
+
this._location = null;
|
|
162
|
+
this._error = null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Handles device information detection
|
|
167
|
+
*/
|
|
168
|
+
class DeviceInfoService {
|
|
169
|
+
static getDeviceInfo() {
|
|
170
|
+
const userAgent = navigator.userAgent;
|
|
171
|
+
const platform = navigator.platform;
|
|
172
|
+
// Detect device type
|
|
173
|
+
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
|
|
174
|
+
const isTablet = /iPad|Android(?=.*Tablet)|Windows NT.*Touch/i.test(userAgent);
|
|
175
|
+
let deviceType = 'unknown';
|
|
176
|
+
if (isTablet)
|
|
177
|
+
deviceType = 'tablet';
|
|
178
|
+
else if (isMobile)
|
|
179
|
+
deviceType = 'mobile';
|
|
180
|
+
else
|
|
181
|
+
deviceType = 'desktop';
|
|
182
|
+
// Detect browser and version
|
|
183
|
+
const browserMatch = userAgent.match(/(firefox|chrome|safari|opera|edge|msie|trident(?=\/))\/?\s*(\d+)/i);
|
|
184
|
+
const browser = browserMatch ? browserMatch[1] : 'Unknown';
|
|
185
|
+
const browserVersion = browserMatch ? browserMatch[2] : '';
|
|
186
|
+
// Detect OS and version
|
|
187
|
+
let os = 'Unknown';
|
|
188
|
+
let osVersion = '';
|
|
189
|
+
if (userAgent.includes('Windows NT')) {
|
|
190
|
+
os = 'Windows';
|
|
191
|
+
const winMatch = userAgent.match(/Windows NT ([\d.]+)/);
|
|
192
|
+
osVersion = winMatch ? winMatch[1] : '';
|
|
193
|
+
}
|
|
194
|
+
else if (userAgent.includes('Mac OS X')) {
|
|
195
|
+
os = 'macOS';
|
|
196
|
+
const macMatch = userAgent.match(/Mac OS X ([\d_]+)/);
|
|
197
|
+
osVersion = macMatch ? macMatch[1].replace(/_/g, '.') : '';
|
|
198
|
+
}
|
|
199
|
+
else if (userAgent.includes('Linux')) {
|
|
200
|
+
os = 'Linux';
|
|
201
|
+
}
|
|
202
|
+
else if (userAgent.includes('Android')) {
|
|
203
|
+
os = 'Android';
|
|
204
|
+
const androidMatch = userAgent.match(/Android ([\d.]+)/);
|
|
205
|
+
osVersion = androidMatch ? androidMatch[1] : '';
|
|
206
|
+
}
|
|
207
|
+
else if (userAgent.includes('iOS')) {
|
|
208
|
+
os = 'iOS';
|
|
209
|
+
const iosMatch = userAgent.match(/OS ([\d_]+)/);
|
|
210
|
+
osVersion = iosMatch ? iosMatch[1].replace(/_/g, '.') : '';
|
|
211
|
+
}
|
|
212
|
+
// Generate device ID
|
|
213
|
+
const deviceId = `${browser}-${os}-${platform}`.replace(/[^a-zA-Z0-9-]/g, '');
|
|
214
|
+
// Get screen resolution and timezone
|
|
215
|
+
const screenResolution = `${screen.width}x${screen.height}`;
|
|
216
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
217
|
+
return {
|
|
218
|
+
id: deviceId,
|
|
219
|
+
type: deviceType,
|
|
220
|
+
browser,
|
|
221
|
+
browserVersion,
|
|
222
|
+
os,
|
|
223
|
+
osVersion,
|
|
224
|
+
userAgent,
|
|
225
|
+
screenResolution,
|
|
226
|
+
timezone
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Main presence tracking service
|
|
232
|
+
*
|
|
233
|
+
* @class FirekitPresence
|
|
234
|
+
* @example
|
|
235
|
+
* ```typescript
|
|
236
|
+
* import { firekitPresence } from 'svelte-firekit';
|
|
237
|
+
*
|
|
238
|
+
* // Initialize
|
|
239
|
+
* await firekitPresence.initialize(user, {
|
|
240
|
+
* geolocation: { enabled: true, type: 'browser' },
|
|
241
|
+
* sessionTTL: 30 * 60 * 1000
|
|
242
|
+
* });
|
|
243
|
+
*
|
|
244
|
+
* // Listen to events
|
|
245
|
+
* const unsubscribe = firekitPresence.addEventListener((event) => {
|
|
246
|
+
* console.log('Presence event:', event);
|
|
247
|
+
* });
|
|
248
|
+
*
|
|
249
|
+
* // Access reactive state
|
|
250
|
+
* $: console.log('Status:', firekitPresence.status);
|
|
251
|
+
* ```
|
|
252
|
+
*/
|
|
253
|
+
class FirekitPresence {
|
|
254
|
+
static instance;
|
|
255
|
+
config = {
|
|
256
|
+
sessionTTL: 30 * 60 * 1000,
|
|
257
|
+
updateInterval: 60 * 1000,
|
|
258
|
+
trackDeviceInfo: true
|
|
259
|
+
};
|
|
260
|
+
geolocationService = null;
|
|
261
|
+
connectedListener = null;
|
|
262
|
+
eventListeners = new Set();
|
|
263
|
+
currentUser = null;
|
|
264
|
+
// Reactive state using Svelte 5 runes
|
|
265
|
+
_initialized = $state(false);
|
|
266
|
+
_status = $state('offline');
|
|
267
|
+
_loading = $state(false);
|
|
268
|
+
_error = $state(null);
|
|
269
|
+
_currentSession = $state(null);
|
|
270
|
+
_sessions = $state([]);
|
|
271
|
+
constructor() {
|
|
272
|
+
if (browser) {
|
|
273
|
+
this.setupVisibilityListener();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Gets singleton instance of FirekitPresence
|
|
278
|
+
*/
|
|
279
|
+
static getInstance() {
|
|
280
|
+
if (!FirekitPresence.instance) {
|
|
281
|
+
FirekitPresence.instance = new FirekitPresence();
|
|
282
|
+
}
|
|
283
|
+
return FirekitPresence.instance;
|
|
284
|
+
}
|
|
285
|
+
// ========================================
|
|
286
|
+
// REACTIVE GETTERS
|
|
287
|
+
// ========================================
|
|
288
|
+
get initialized() {
|
|
289
|
+
return this._initialized;
|
|
290
|
+
}
|
|
291
|
+
get status() {
|
|
292
|
+
return this._status;
|
|
293
|
+
}
|
|
294
|
+
get loading() {
|
|
295
|
+
return this._loading;
|
|
296
|
+
}
|
|
297
|
+
get error() {
|
|
298
|
+
return this._error;
|
|
299
|
+
}
|
|
300
|
+
get currentSession() {
|
|
301
|
+
return this._currentSession;
|
|
302
|
+
}
|
|
303
|
+
get sessions() {
|
|
304
|
+
return this._sessions;
|
|
305
|
+
}
|
|
306
|
+
get location() {
|
|
307
|
+
return this.geolocationService?.location || null;
|
|
308
|
+
}
|
|
309
|
+
get hasLocationConsent() {
|
|
310
|
+
return this.geolocationService?.hasConsent || false;
|
|
311
|
+
}
|
|
312
|
+
// ========================================
|
|
313
|
+
// INITIALIZATION
|
|
314
|
+
// ========================================
|
|
315
|
+
/**
|
|
316
|
+
* Initialize presence tracking
|
|
317
|
+
* @param user Current authenticated user
|
|
318
|
+
* @param config Presence configuration options
|
|
319
|
+
*/
|
|
320
|
+
async initialize(user, config) {
|
|
321
|
+
try {
|
|
322
|
+
if (!browser) {
|
|
323
|
+
throw new PresenceError(PresenceErrorCode.INITIALIZATION_FAILED, 'Presence service can only be initialized in browser environment');
|
|
324
|
+
}
|
|
325
|
+
if (this._initialized) {
|
|
326
|
+
console.warn('Presence service is already initialized');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
this._loading = true;
|
|
330
|
+
this.currentUser = user;
|
|
331
|
+
this.config = { ...this.config, ...config };
|
|
332
|
+
// Initialize geolocation service if enabled
|
|
333
|
+
if (this.config.geolocation?.enabled) {
|
|
334
|
+
this.geolocationService = new GeolocationService(this.config.geolocation);
|
|
335
|
+
if (this.config.geolocation.requireConsent) {
|
|
336
|
+
this.emitEvent({ type: 'consent_requested', timestamp: Date.now() });
|
|
337
|
+
const hasConsent = await this.geolocationService.requestConsent();
|
|
338
|
+
this.emitEvent({
|
|
339
|
+
type: hasConsent ? 'consent_granted' : 'consent_denied',
|
|
340
|
+
timestamp: Date.now()
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
// Start location tracking if consent granted
|
|
344
|
+
if (!this.config.geolocation.requireConsent || this.geolocationService.hasConsent) {
|
|
345
|
+
this.geolocationService.startWatching(this.config.updateInterval);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Set up Firebase connection monitoring
|
|
349
|
+
await this.setupConnectionMonitoring();
|
|
350
|
+
this._initialized = true;
|
|
351
|
+
this.emitEvent({
|
|
352
|
+
type: 'init',
|
|
353
|
+
data: { userId: user.uid, config: this.config },
|
|
354
|
+
timestamp: Date.now()
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
this._error =
|
|
359
|
+
error instanceof PresenceError
|
|
360
|
+
? error
|
|
361
|
+
: new PresenceError(PresenceErrorCode.INITIALIZATION_FAILED, `Failed to initialize presence service: ${error.message}`, error);
|
|
362
|
+
this.emitEvent({
|
|
363
|
+
type: 'error',
|
|
364
|
+
error: this._error,
|
|
365
|
+
timestamp: Date.now()
|
|
366
|
+
});
|
|
367
|
+
throw this._error;
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
this._loading = false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Set up Firebase connection monitoring
|
|
375
|
+
*/
|
|
376
|
+
async setupConnectionMonitoring() {
|
|
377
|
+
const db = firebaseService.getDatabaseInstance();
|
|
378
|
+
if (!db) {
|
|
379
|
+
throw new PresenceError(PresenceErrorCode.DATABASE_ERROR, 'Firebase Database instance not available');
|
|
380
|
+
}
|
|
381
|
+
const connectedRef = ref(db, '.info/connected');
|
|
382
|
+
this.connectedListener = onValue(connectedRef, async (snapshot) => {
|
|
383
|
+
if (snapshot.val() === true) {
|
|
384
|
+
await this.setPresence('online');
|
|
385
|
+
await this.setupDisconnectHandler();
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
await this.setPresence('offline');
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Set up disconnect handler for graceful offline transitions
|
|
394
|
+
*/
|
|
395
|
+
async setupDisconnectHandler() {
|
|
396
|
+
if (!this.currentUser || !this._currentSession)
|
|
397
|
+
return;
|
|
398
|
+
const db = firebaseService.getDatabaseInstance();
|
|
399
|
+
if (!db)
|
|
400
|
+
return;
|
|
401
|
+
const sessionPath = this.config.sessionPath || 'presence';
|
|
402
|
+
const sessionRef = ref(db, `${sessionPath}/${this.currentUser.uid}/sessions/${this._currentSession.id}`);
|
|
403
|
+
await onDisconnect(sessionRef).update({
|
|
404
|
+
status: 'offline',
|
|
405
|
+
lastSeen: serverTimestamp()
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Set up page visibility listener
|
|
410
|
+
*/
|
|
411
|
+
setupVisibilityListener() {
|
|
412
|
+
if (!browser)
|
|
413
|
+
return;
|
|
414
|
+
document.addEventListener('visibilitychange', async () => {
|
|
415
|
+
if (!this._initialized)
|
|
416
|
+
return;
|
|
417
|
+
if (document.visibilityState === 'hidden') {
|
|
418
|
+
await this.setPresence('away');
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
await this.setPresence('online');
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// ========================================
|
|
426
|
+
// PRESENCE MANAGEMENT
|
|
427
|
+
// ========================================
|
|
428
|
+
/**
|
|
429
|
+
* Set presence status
|
|
430
|
+
*/
|
|
431
|
+
async setPresence(status) {
|
|
432
|
+
try {
|
|
433
|
+
if (!this.currentUser) {
|
|
434
|
+
throw new PresenceError(PresenceErrorCode.USER_NOT_AUTHENTICATED, 'No authenticated user found');
|
|
435
|
+
}
|
|
436
|
+
const db = firebaseService.getDatabaseInstance();
|
|
437
|
+
if (!db) {
|
|
438
|
+
throw new PresenceError(PresenceErrorCode.DATABASE_ERROR, 'Firebase Database instance not available');
|
|
439
|
+
}
|
|
440
|
+
// Get current location if available
|
|
441
|
+
const location = await this.geolocationService?.getCurrentLocation();
|
|
442
|
+
// Get device info if enabled
|
|
443
|
+
const device = this.config.trackDeviceInfo ? DeviceInfoService.getDeviceInfo() : undefined;
|
|
444
|
+
let session;
|
|
445
|
+
if (!this._currentSession) {
|
|
446
|
+
// Create new session
|
|
447
|
+
session = {
|
|
448
|
+
id: `${this.currentUser.uid}_${device?.id || Date.now()}`,
|
|
449
|
+
userId: this.currentUser.uid,
|
|
450
|
+
status,
|
|
451
|
+
createdAt: new Date().toISOString(),
|
|
452
|
+
lastSeen: new Date().toISOString(),
|
|
453
|
+
lastActivity: new Date().toISOString(),
|
|
454
|
+
...(location && { location }),
|
|
455
|
+
...(device && { device }),
|
|
456
|
+
...(this.config.customMetadata && { metadata: this.config.customMetadata })
|
|
457
|
+
};
|
|
458
|
+
this.emitEvent({
|
|
459
|
+
type: 'session_created',
|
|
460
|
+
data: { session },
|
|
461
|
+
timestamp: Date.now(),
|
|
462
|
+
sessionId: session.id,
|
|
463
|
+
userId: session.userId
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
// Update existing session
|
|
468
|
+
session = {
|
|
469
|
+
...this._currentSession,
|
|
470
|
+
status,
|
|
471
|
+
lastSeen: new Date().toISOString(),
|
|
472
|
+
lastActivity: new Date().toISOString(),
|
|
473
|
+
...(location && { location })
|
|
474
|
+
};
|
|
475
|
+
this.emitEvent({
|
|
476
|
+
type: 'session_updated',
|
|
477
|
+
data: { session, previousStatus: this._currentSession.status },
|
|
478
|
+
timestamp: Date.now(),
|
|
479
|
+
sessionId: session.id,
|
|
480
|
+
userId: session.userId
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
// Save session to Firebase
|
|
484
|
+
const sessionPath = this.config.sessionPath || 'presence';
|
|
485
|
+
const sessionRef = ref(db, `${sessionPath}/${this.currentUser.uid}/sessions/${session.id}`);
|
|
486
|
+
await set(sessionRef, session);
|
|
487
|
+
// Update local state
|
|
488
|
+
this._currentSession = session;
|
|
489
|
+
this._status = status;
|
|
490
|
+
// Load and update all sessions
|
|
491
|
+
await this.loadSessions();
|
|
492
|
+
this.emitEvent({
|
|
493
|
+
type: 'status_change',
|
|
494
|
+
data: { status, session, location },
|
|
495
|
+
timestamp: Date.now(),
|
|
496
|
+
sessionId: session.id,
|
|
497
|
+
userId: session.userId
|
|
498
|
+
});
|
|
499
|
+
// Emit location update if location changed
|
|
500
|
+
if (location) {
|
|
501
|
+
this.emitEvent({
|
|
502
|
+
type: 'location_update',
|
|
503
|
+
data: { location, session },
|
|
504
|
+
timestamp: Date.now(),
|
|
505
|
+
sessionId: session.id,
|
|
506
|
+
userId: session.userId
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
this._error =
|
|
512
|
+
error instanceof PresenceError
|
|
513
|
+
? error
|
|
514
|
+
: new PresenceError(PresenceErrorCode.DATABASE_ERROR, `Failed to set presence: ${error.message}`, error);
|
|
515
|
+
this.emitEvent({
|
|
516
|
+
type: 'error',
|
|
517
|
+
error: this._error,
|
|
518
|
+
timestamp: Date.now()
|
|
519
|
+
});
|
|
520
|
+
throw this._error;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Load all sessions for current user
|
|
525
|
+
*/
|
|
526
|
+
async loadSessions() {
|
|
527
|
+
if (!this.currentUser)
|
|
528
|
+
return;
|
|
529
|
+
try {
|
|
530
|
+
const db = firebaseService.getDatabaseInstance();
|
|
531
|
+
if (!db)
|
|
532
|
+
return;
|
|
533
|
+
const sessionPath = this.config.sessionPath || 'presence';
|
|
534
|
+
const sessionsRef = ref(db, `${sessionPath}/${this.currentUser.uid}/sessions`);
|
|
535
|
+
const snapshot = await get(sessionsRef);
|
|
536
|
+
if (snapshot.exists()) {
|
|
537
|
+
const sessionsData = snapshot.val();
|
|
538
|
+
let sessions = Object.values(sessionsData);
|
|
539
|
+
// Clean up stale sessions
|
|
540
|
+
if (this.config.sessionTTL) {
|
|
541
|
+
const cutoffTime = new Date(Date.now() - this.config.sessionTTL).toISOString();
|
|
542
|
+
const expiredSessions = sessions.filter((session) => session.lastSeen < cutoffTime);
|
|
543
|
+
sessions = sessions.filter((session) => session.lastSeen >= cutoffTime);
|
|
544
|
+
// Remove stale sessions from database and emit events
|
|
545
|
+
for (const expiredSession of expiredSessions) {
|
|
546
|
+
const staleSessionRef = ref(db, `${sessionPath}/${this.currentUser.uid}/sessions/${expiredSession.id}`);
|
|
547
|
+
await set(staleSessionRef, null);
|
|
548
|
+
this.emitEvent({
|
|
549
|
+
type: 'session_expired',
|
|
550
|
+
data: { session: expiredSession },
|
|
551
|
+
timestamp: Date.now(),
|
|
552
|
+
sessionId: expiredSession.id,
|
|
553
|
+
userId: expiredSession.userId
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
this._sessions = sessions;
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
this._sessions = [];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
console.error('Failed to load sessions:', error);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// ========================================
|
|
568
|
+
// UTILITY METHODS
|
|
569
|
+
// ========================================
|
|
570
|
+
/**
|
|
571
|
+
* Get presence statistics
|
|
572
|
+
*/
|
|
573
|
+
getPresenceStats() {
|
|
574
|
+
const sessions = this._sessions;
|
|
575
|
+
let totalSessionTime = 0;
|
|
576
|
+
const deviceSet = new Set();
|
|
577
|
+
let lastActivityTime = 0;
|
|
578
|
+
sessions.forEach((session) => {
|
|
579
|
+
if (session.device?.id) {
|
|
580
|
+
deviceSet.add(session.device.id);
|
|
581
|
+
}
|
|
582
|
+
const sessionStart = new Date(session.createdAt).getTime();
|
|
583
|
+
const sessionEnd = new Date(session.lastSeen).getTime();
|
|
584
|
+
totalSessionTime += sessionEnd - sessionStart;
|
|
585
|
+
if (session.lastActivity) {
|
|
586
|
+
const activityTime = new Date(session.lastActivity).getTime();
|
|
587
|
+
lastActivityTime = Math.max(lastActivityTime, activityTime);
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
const averageSessionDuration = sessions.length > 0 ? totalSessionTime / sessions.length : 0;
|
|
591
|
+
return {
|
|
592
|
+
totalSessions: sessions.length,
|
|
593
|
+
onlineSessions: sessions.filter((s) => s.status === 'online').length,
|
|
594
|
+
awaySessions: sessions.filter((s) => s.status === 'away').length,
|
|
595
|
+
offlineSessions: sessions.filter((s) => s.status === 'offline').length,
|
|
596
|
+
uniqueDevices: deviceSet.size,
|
|
597
|
+
averageSessionDuration,
|
|
598
|
+
lastActivity: lastActivityTime > 0 ? new Date(lastActivityTime).toISOString() : ''
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Request location consent manually
|
|
603
|
+
*/
|
|
604
|
+
async requestLocationConsent() {
|
|
605
|
+
if (!this.geolocationService) {
|
|
606
|
+
throw new PresenceError(PresenceErrorCode.GEOLOCATION_UNAVAILABLE, 'Geolocation service not available');
|
|
607
|
+
}
|
|
608
|
+
return await this.geolocationService.requestConsent();
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Get current user's online sessions count
|
|
612
|
+
*/
|
|
613
|
+
getOnlineSessionsCount() {
|
|
614
|
+
return this._sessions.filter((session) => session.status === 'online').length;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Check if user is online on any device
|
|
618
|
+
*/
|
|
619
|
+
isUserOnline() {
|
|
620
|
+
return this._sessions.some((session) => session.status === 'online');
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Get sessions by status
|
|
624
|
+
*/
|
|
625
|
+
getSessionsByStatus(status) {
|
|
626
|
+
return this._sessions.filter((session) => session.status === status);
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Force refresh presence data
|
|
630
|
+
*/
|
|
631
|
+
async refresh() {
|
|
632
|
+
if (!this._initialized)
|
|
633
|
+
return;
|
|
634
|
+
this._loading = true;
|
|
635
|
+
try {
|
|
636
|
+
await this.loadSessions();
|
|
637
|
+
if (this._currentSession) {
|
|
638
|
+
await this.setPresence(this._status);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
finally {
|
|
642
|
+
this._loading = false;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// ========================================
|
|
646
|
+
// EVENT MANAGEMENT
|
|
647
|
+
// ========================================
|
|
648
|
+
/**
|
|
649
|
+
* Add event listener
|
|
650
|
+
* @param callback Event callback function
|
|
651
|
+
* @returns Cleanup function to remove listener
|
|
652
|
+
*/
|
|
653
|
+
addEventListener(callback) {
|
|
654
|
+
this.eventListeners.add(callback);
|
|
655
|
+
return () => this.eventListeners.delete(callback);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Emit event to all listeners
|
|
659
|
+
*/
|
|
660
|
+
emitEvent(event) {
|
|
661
|
+
this.eventListeners.forEach((callback) => {
|
|
662
|
+
try {
|
|
663
|
+
callback(event);
|
|
664
|
+
}
|
|
665
|
+
catch (error) {
|
|
666
|
+
console.error('Error in presence event listener:', error);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
// ========================================
|
|
671
|
+
// CLEANUP
|
|
672
|
+
// ========================================
|
|
673
|
+
/**
|
|
674
|
+
* Dispose of all resources and cleanup
|
|
675
|
+
*/
|
|
676
|
+
async dispose() {
|
|
677
|
+
try {
|
|
678
|
+
// Set status to offline before cleanup
|
|
679
|
+
if (this._currentSession) {
|
|
680
|
+
await this.setPresence('offline');
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
catch (error) {
|
|
684
|
+
console.error('Error setting offline status during disposal:', error);
|
|
685
|
+
}
|
|
686
|
+
// Stop location tracking
|
|
687
|
+
this.geolocationService?.dispose();
|
|
688
|
+
// Remove connection listener
|
|
689
|
+
if (this.connectedListener) {
|
|
690
|
+
this.connectedListener();
|
|
691
|
+
this.connectedListener = null;
|
|
692
|
+
}
|
|
693
|
+
// Clear event listeners
|
|
694
|
+
this.eventListeners.clear();
|
|
695
|
+
// Reset state
|
|
696
|
+
this._initialized = false;
|
|
697
|
+
this._status = 'offline';
|
|
698
|
+
this._loading = false;
|
|
699
|
+
this._error = null;
|
|
700
|
+
this._currentSession = null;
|
|
701
|
+
this._sessions = [];
|
|
702
|
+
this.emitEvent({
|
|
703
|
+
type: 'disconnect',
|
|
704
|
+
timestamp: Date.now()
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Pre-initialized singleton instance of FirekitPresence.
|
|
710
|
+
*
|
|
711
|
+
* @example
|
|
712
|
+
* ```typescript
|
|
713
|
+
* import { firekitPresence } from 'svelte-firekit';
|
|
714
|
+
*
|
|
715
|
+
* // Initialize presence tracking
|
|
716
|
+
* await firekitPresence.initialize(user, {
|
|
717
|
+
* geolocation: { enabled: true, type: 'browser' },
|
|
718
|
+
* sessionTTL: 30 * 60 * 1000
|
|
719
|
+
* });
|
|
720
|
+
*
|
|
721
|
+
* // Listen to events
|
|
722
|
+
* const unsubscribe = firekitPresence.addEventListener((event) => {
|
|
723
|
+
* console.log('Presence event:', event.type, event.data);
|
|
724
|
+
* });
|
|
725
|
+
* ```
|
|
726
|
+
*/
|
|
727
|
+
export const firekitPresence = FirekitPresence.getInstance();
|