833 lines
23 KiB
TypeScript
833 lines
23 KiB
TypeScript
/**
|
|
* Presence Manager
|
|
*
|
|
* Manages real-time location sharing with privacy controls.
|
|
* Integrates with zkGPS for commitments and trust circles for
|
|
* precision-based sharing.
|
|
*/
|
|
|
|
import type {
|
|
UserPresence,
|
|
LocationPresence,
|
|
PresenceStatus,
|
|
PresenceBroadcast,
|
|
LocationBroadcastPayload,
|
|
StatusBroadcastPayload,
|
|
ProximityBroadcastPayload,
|
|
PrecisionLevel,
|
|
PresenceView,
|
|
ViewableLocation,
|
|
ProximityInfo,
|
|
PresenceChannelConfig,
|
|
PresenceChannelState,
|
|
PresenceEvent,
|
|
PresenceEventListener,
|
|
LocationSource,
|
|
} from './types';
|
|
import {
|
|
DEFAULT_PRESENCE_CONFIG,
|
|
TRUST_LEVEL_PRECISION,
|
|
getRadiusForPrecision,
|
|
getPrecisionForTrustLevel,
|
|
} from './types';
|
|
import type { TrustLevel, GeohashCommitment, GeohashPrecision } from '../privacy/types';
|
|
import { TrustCircleManager, createTrustCircleManager } from '../privacy/trustCircles';
|
|
import { createCommitment, generateSalt } from '../privacy/commitments';
|
|
import { encodeGeohash, getGeohashBounds } from '../privacy/geohash';
|
|
|
|
// =============================================================================
|
|
// Presence Manager
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Manages presence for a channel
|
|
*/
|
|
export class PresenceManager {
|
|
private config: PresenceChannelConfig;
|
|
private state: PresenceChannelState;
|
|
private trustCircles: TrustCircleManager;
|
|
private listeners: Set<PresenceEventListener> = new Set();
|
|
private updateTimer: ReturnType<typeof setInterval> | null = null;
|
|
private locationWatchId: number | null = null;
|
|
private lastLocationUpdate: number = 0;
|
|
private broadcastCallback: ((broadcast: PresenceBroadcast) => void) | null = null;
|
|
|
|
constructor(
|
|
config: Partial<PresenceChannelConfig> & Pick<PresenceChannelConfig, 'channelId' | 'userPubKey' | 'userPrivKey' | 'displayName' | 'color'>
|
|
) {
|
|
this.config = {
|
|
...DEFAULT_PRESENCE_CONFIG,
|
|
...config,
|
|
};
|
|
|
|
this.trustCircles = createTrustCircleManager(this.config.userPubKey, this.config.userPubKey);
|
|
|
|
this.state = {
|
|
config: this.config,
|
|
self: {
|
|
pubKey: this.config.userPubKey,
|
|
displayName: this.config.displayName,
|
|
color: this.config.color,
|
|
location: null,
|
|
status: 'online',
|
|
lastSeen: new Date(),
|
|
isMoving: false,
|
|
deviceType: this.detectDeviceType(),
|
|
},
|
|
others: new Map(),
|
|
views: new Map(),
|
|
connectionState: 'connecting',
|
|
lastSequence: 0,
|
|
};
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Lifecycle
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Start presence sharing
|
|
*/
|
|
start(broadcastCallback: (broadcast: PresenceBroadcast) => void): void {
|
|
this.broadcastCallback = broadcastCallback;
|
|
this.state.connectionState = 'connected';
|
|
|
|
// Start periodic presence updates
|
|
this.updateTimer = setInterval(() => {
|
|
this.broadcastPresence();
|
|
}, this.config.updateInterval);
|
|
|
|
// Broadcast initial presence
|
|
this.broadcastPresence();
|
|
|
|
this.emit({ type: 'connection:changed', state: 'connected' });
|
|
}
|
|
|
|
/**
|
|
* Stop presence sharing
|
|
*/
|
|
stop(): void {
|
|
if (this.updateTimer) {
|
|
clearInterval(this.updateTimer);
|
|
this.updateTimer = null;
|
|
}
|
|
|
|
this.stopLocationWatch();
|
|
|
|
// Broadcast leave message
|
|
if (this.broadcastCallback) {
|
|
this.broadcastCallback(this.createBroadcast('leave', null));
|
|
}
|
|
|
|
this.state.connectionState = 'disconnected';
|
|
this.emit({ type: 'connection:changed', state: 'disconnected' });
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Location Sharing
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Start watching device location
|
|
*/
|
|
startLocationWatch(): void {
|
|
if (!navigator.geolocation) {
|
|
console.warn('Geolocation not available');
|
|
return;
|
|
}
|
|
|
|
this.locationWatchId = navigator.geolocation.watchPosition(
|
|
(position) => this.handleLocationUpdate(position),
|
|
(error) => this.handleLocationError(error),
|
|
{
|
|
enableHighAccuracy: true,
|
|
maximumAge: 5000,
|
|
timeout: 10000,
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stop watching device location
|
|
*/
|
|
stopLocationWatch(): void {
|
|
if (this.locationWatchId !== null && navigator.geolocation) {
|
|
navigator.geolocation.clearWatch(this.locationWatchId);
|
|
this.locationWatchId = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle location update from device
|
|
*/
|
|
private async handleLocationUpdate(position: GeolocationPosition): Promise<void> {
|
|
const now = Date.now();
|
|
|
|
// Throttle updates
|
|
if (now - this.lastLocationUpdate < this.config.locationThrottle) {
|
|
return;
|
|
}
|
|
this.lastLocationUpdate = now;
|
|
|
|
const coords = position.coords;
|
|
|
|
// Determine if moving based on speed
|
|
const isMoving = (coords.speed ?? 0) > 0.5; // > 0.5 m/s = moving
|
|
|
|
// Create zkGPS commitment for the location
|
|
const fullGeohash = encodeGeohash(coords.latitude, coords.longitude, 12);
|
|
const salt = generateSalt();
|
|
const baseCommitment = await createCommitment({
|
|
coordinate: { lat: coords.latitude, lng: coords.longitude },
|
|
precision: 12 as GeohashPrecision,
|
|
salt,
|
|
});
|
|
|
|
// Create GeohashCommitment from LocationCommitment
|
|
const commitment: GeohashCommitment = {
|
|
commitment: baseCommitment.commitment,
|
|
geohash: fullGeohash,
|
|
precision: baseCommitment.precision,
|
|
timestamp: baseCommitment.timestamp,
|
|
expiresAt: baseCommitment.expiresAt,
|
|
salt,
|
|
};
|
|
|
|
// Update self location
|
|
this.state.self.location = {
|
|
coordinates: {
|
|
latitude: coords.latitude,
|
|
longitude: coords.longitude,
|
|
altitude: coords.altitude ?? undefined,
|
|
accuracy: coords.accuracy,
|
|
heading: coords.heading ?? undefined,
|
|
speed: coords.speed ?? undefined,
|
|
},
|
|
commitment,
|
|
timestamp: new Date(position.timestamp),
|
|
source: 'gps',
|
|
isLive: true,
|
|
};
|
|
|
|
this.state.self.isMoving = isMoving;
|
|
this.state.self.lastSeen = new Date();
|
|
|
|
// Broadcast location update
|
|
this.broadcastLocation();
|
|
}
|
|
|
|
/**
|
|
* Handle location error
|
|
*/
|
|
private handleLocationError(error: GeolocationPositionError): void {
|
|
console.warn('Location error:', error.message);
|
|
this.emit({ type: 'error', error: `Location error: ${error.message}` });
|
|
}
|
|
|
|
/**
|
|
* Manually set location (for testing or manual input)
|
|
*/
|
|
async setLocation(
|
|
latitude: number,
|
|
longitude: number,
|
|
source: LocationSource = 'manual'
|
|
): Promise<void> {
|
|
const salt = generateSalt();
|
|
const fullGeohash = encodeGeohash(latitude, longitude, 12);
|
|
const baseCommitment = await createCommitment({
|
|
coordinate: { lat: latitude, lng: longitude },
|
|
precision: 12 as GeohashPrecision,
|
|
salt,
|
|
});
|
|
|
|
// Create GeohashCommitment from LocationCommitment
|
|
const commitment: GeohashCommitment = {
|
|
commitment: baseCommitment.commitment,
|
|
geohash: fullGeohash,
|
|
precision: baseCommitment.precision,
|
|
timestamp: baseCommitment.timestamp,
|
|
expiresAt: baseCommitment.expiresAt,
|
|
salt,
|
|
};
|
|
|
|
this.state.self.location = {
|
|
coordinates: {
|
|
latitude,
|
|
longitude,
|
|
},
|
|
commitment,
|
|
timestamp: new Date(),
|
|
source,
|
|
isLive: source === 'gps',
|
|
};
|
|
|
|
this.state.self.lastSeen = new Date();
|
|
this.broadcastLocation();
|
|
}
|
|
|
|
/**
|
|
* Clear current location (stop sharing)
|
|
*/
|
|
clearLocation(): void {
|
|
this.state.self.location = null;
|
|
this.broadcastPresence();
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Broadcasting
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Broadcast current presence
|
|
*/
|
|
private broadcastPresence(): void {
|
|
if (!this.broadcastCallback) return;
|
|
|
|
if (this.state.self.location) {
|
|
this.broadcastLocation();
|
|
} else {
|
|
this.broadcastStatus();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Broadcast location update
|
|
*/
|
|
private broadcastLocation(): void {
|
|
if (!this.broadcastCallback || !this.state.self.location) return;
|
|
|
|
const location = this.state.self.location;
|
|
|
|
// Create precision levels for each trust level
|
|
const precisionLevels: PrecisionLevel[] = [];
|
|
const fullGeohash = location.commitment.geohash;
|
|
|
|
for (const [level, precision] of Object.entries(TRUST_LEVEL_PRECISION)) {
|
|
precisionLevels.push({
|
|
trustLevel: level as TrustLevel,
|
|
geohash: fullGeohash.substring(0, precision),
|
|
precision,
|
|
});
|
|
}
|
|
|
|
const payload: LocationBroadcastPayload = {
|
|
commitment: location.commitment,
|
|
precisionLevels,
|
|
isMoving: this.state.self.isMoving,
|
|
heading: location.coordinates.heading,
|
|
speedCategory: this.getSpeedCategory(location.coordinates.speed),
|
|
};
|
|
|
|
const broadcast = this.createBroadcast('location', payload);
|
|
this.broadcastCallback(broadcast);
|
|
}
|
|
|
|
/**
|
|
* Broadcast status update
|
|
*/
|
|
private broadcastStatus(): void {
|
|
if (!this.broadcastCallback) return;
|
|
|
|
const payload: StatusBroadcastPayload = {
|
|
status: this.state.self.status,
|
|
message: this.state.self.statusMessage,
|
|
deviceType: this.state.self.deviceType,
|
|
};
|
|
|
|
const broadcast = this.createBroadcast('status', payload);
|
|
this.broadcastCallback(broadcast);
|
|
}
|
|
|
|
/**
|
|
* Create a broadcast message
|
|
*/
|
|
private createBroadcast(
|
|
type: PresenceBroadcast['type'],
|
|
payload: PresenceBroadcast['payload']
|
|
): PresenceBroadcast {
|
|
this.state.lastSequence++;
|
|
|
|
return {
|
|
senderPubKey: this.config.userPubKey,
|
|
type,
|
|
payload,
|
|
signature: this.signBroadcast(type, payload),
|
|
timestamp: new Date(),
|
|
sequence: this.state.lastSequence,
|
|
ttl: this.config.presenceTtl,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sign a broadcast (simplified - in production use proper crypto)
|
|
*/
|
|
private signBroadcast(type: string, payload: any): string {
|
|
const message = JSON.stringify({ type, payload, key: this.config.userPrivKey });
|
|
// In production, use proper signing
|
|
let hash = 0;
|
|
for (let i = 0; i < message.length; i++) {
|
|
hash = (hash << 5) - hash + message.charCodeAt(i);
|
|
hash = hash & hash;
|
|
}
|
|
return hash.toString(16);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Receiving
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Handle incoming broadcast from another user
|
|
*/
|
|
handleBroadcast(broadcast: PresenceBroadcast): void {
|
|
// Ignore our own broadcasts
|
|
if (broadcast.senderPubKey === this.config.userPubKey) return;
|
|
|
|
// Check TTL
|
|
const age = (Date.now() - broadcast.timestamp.getTime()) / 1000;
|
|
if (age > broadcast.ttl) {
|
|
return; // Expired
|
|
}
|
|
|
|
switch (broadcast.type) {
|
|
case 'location':
|
|
this.handleLocationBroadcast(broadcast);
|
|
break;
|
|
case 'status':
|
|
this.handleStatusBroadcast(broadcast);
|
|
break;
|
|
case 'proximity':
|
|
this.handleProximityBroadcast(broadcast);
|
|
break;
|
|
case 'leave':
|
|
this.handleLeaveBroadcast(broadcast);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle location broadcast
|
|
*/
|
|
private handleLocationBroadcast(broadcast: PresenceBroadcast): void {
|
|
const payload = broadcast.payload as LocationBroadcastPayload;
|
|
const senderKey = broadcast.senderPubKey;
|
|
|
|
// Get or create user presence
|
|
let user = this.state.others.get(senderKey);
|
|
const isNew = !user;
|
|
|
|
if (!user) {
|
|
user = {
|
|
pubKey: senderKey,
|
|
displayName: senderKey.substring(0, 8) + '...',
|
|
color: this.generateUserColor(senderKey),
|
|
location: null,
|
|
status: 'online',
|
|
lastSeen: new Date(),
|
|
isMoving: false,
|
|
deviceType: 'unknown',
|
|
};
|
|
this.state.others.set(senderKey, user);
|
|
}
|
|
|
|
// Update user's location (we store the commitment, not decoded location)
|
|
user.location = {
|
|
coordinates: { latitude: 0, longitude: 0 }, // We don't know exact coords
|
|
commitment: payload.commitment,
|
|
timestamp: broadcast.timestamp,
|
|
source: 'network' as LocationSource,
|
|
isLive: true,
|
|
};
|
|
user.isMoving = payload.isMoving;
|
|
user.lastSeen = broadcast.timestamp;
|
|
user.status = 'online';
|
|
|
|
// Create view for this user based on trust level
|
|
const view = this.createPresenceView(user, payload);
|
|
this.state.views.set(senderKey, view);
|
|
|
|
if (isNew) {
|
|
this.emit({ type: 'user:joined', user });
|
|
} else {
|
|
this.emit({ type: 'user:updated', user, changes: ['location'] });
|
|
}
|
|
|
|
if (view.location) {
|
|
this.emit({ type: 'location:updated', pubKey: senderKey, location: view.location });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle status broadcast
|
|
*/
|
|
private handleStatusBroadcast(broadcast: PresenceBroadcast): void {
|
|
const payload = broadcast.payload as StatusBroadcastPayload;
|
|
const senderKey = broadcast.senderPubKey;
|
|
|
|
let user = this.state.others.get(senderKey);
|
|
if (!user) {
|
|
user = {
|
|
pubKey: senderKey,
|
|
displayName: senderKey.substring(0, 8) + '...',
|
|
color: this.generateUserColor(senderKey),
|
|
location: null,
|
|
status: payload.status,
|
|
lastSeen: broadcast.timestamp,
|
|
isMoving: false,
|
|
deviceType: payload.deviceType ?? 'unknown',
|
|
};
|
|
this.state.others.set(senderKey, user);
|
|
this.emit({ type: 'user:joined', user });
|
|
} else {
|
|
user.status = payload.status;
|
|
user.statusMessage = payload.message;
|
|
user.lastSeen = broadcast.timestamp;
|
|
if (payload.deviceType) user.deviceType = payload.deviceType;
|
|
this.emit({ type: 'status:changed', pubKey: senderKey, status: payload.status });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle proximity broadcast
|
|
*/
|
|
private handleProximityBroadcast(broadcast: PresenceBroadcast): void {
|
|
const payload = broadcast.payload as ProximityBroadcastPayload;
|
|
|
|
// Only process if we're the target
|
|
if (payload.targetPubKey !== this.config.userPubKey) return;
|
|
|
|
const senderKey = broadcast.senderPubKey;
|
|
const view = this.state.views.get(senderKey);
|
|
|
|
if (view) {
|
|
view.proximity = {
|
|
category: payload.distanceCategory,
|
|
verified: true, // Has proof
|
|
mutuallyVisible: true,
|
|
};
|
|
|
|
this.emit({ type: 'proximity:detected', pubKey: senderKey, proximity: view.proximity });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle leave broadcast
|
|
*/
|
|
private handleLeaveBroadcast(broadcast: PresenceBroadcast): void {
|
|
const senderKey = broadcast.senderPubKey;
|
|
|
|
this.state.others.delete(senderKey);
|
|
this.state.views.delete(senderKey);
|
|
|
|
this.emit({ type: 'user:left', pubKey: senderKey });
|
|
}
|
|
|
|
/**
|
|
* Create a presence view based on trust level
|
|
*/
|
|
private createPresenceView(
|
|
user: UserPresence,
|
|
payload: LocationBroadcastPayload
|
|
): PresenceView {
|
|
// Get trust level for this user
|
|
const trustLevel = this.trustCircles.getTrustLevel(user.pubKey) ?? 'public';
|
|
|
|
// Find the precision level for our trust relationship
|
|
const precisionLevel = payload.precisionLevels.find(
|
|
(p) => p.trustLevel === trustLevel
|
|
);
|
|
|
|
let location: ViewableLocation | null = null;
|
|
|
|
if (precisionLevel) {
|
|
const geohash = precisionLevel.geohash;
|
|
const bounds = getGeohashBounds(geohash);
|
|
const center = {
|
|
latitude: (bounds.minLat + bounds.maxLat) / 2,
|
|
longitude: (bounds.minLng + bounds.maxLng) / 2,
|
|
};
|
|
|
|
const ageSeconds = (Date.now() - payload.commitment.timestamp) / 1000;
|
|
|
|
location = {
|
|
geohash,
|
|
precision: precisionLevel.precision,
|
|
center,
|
|
bounds,
|
|
uncertaintyRadius: getRadiusForPrecision(precisionLevel.precision),
|
|
ageSeconds,
|
|
isMoving: payload.isMoving,
|
|
heading: payload.heading,
|
|
speedCategory: payload.speedCategory,
|
|
};
|
|
}
|
|
|
|
// Calculate proximity if we have our own location
|
|
let proximity: ProximityInfo | undefined;
|
|
if (location && this.state.self.location) {
|
|
proximity = this.calculateProximity(location);
|
|
}
|
|
|
|
return {
|
|
user: {
|
|
pubKey: user.pubKey,
|
|
displayName: user.displayName,
|
|
color: user.color,
|
|
},
|
|
location,
|
|
status: user.status,
|
|
lastSeen: user.lastSeen,
|
|
trustLevel,
|
|
isVerified: true, // Has commitment
|
|
proximity,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate proximity to another user
|
|
*/
|
|
private calculateProximity(otherLocation: ViewableLocation): ProximityInfo {
|
|
if (!this.state.self.location) {
|
|
return { category: 'far', verified: false, mutuallyVisible: false };
|
|
}
|
|
|
|
const myCoords = this.state.self.location.coordinates;
|
|
const distance = this.haversineDistance(
|
|
myCoords.latitude,
|
|
myCoords.longitude,
|
|
otherLocation.center.latitude,
|
|
otherLocation.center.longitude
|
|
);
|
|
|
|
let category: ProximityInfo['category'];
|
|
if (distance < 50) category = 'here';
|
|
else if (distance < 500) category = 'nearby';
|
|
else if (distance < 5000) category = 'same-area';
|
|
else if (distance < 50000) category = 'same-city';
|
|
else category = 'far';
|
|
|
|
return {
|
|
category,
|
|
verified: false,
|
|
approximateMeters: distance,
|
|
mutuallyVisible: distance < otherLocation.uncertaintyRadius * 2,
|
|
};
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Trust Circle Management
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Set trust level for a contact
|
|
*/
|
|
setTrustLevel(pubKey: string, level: TrustLevel): void {
|
|
this.trustCircles.setTrustLevel(pubKey, level);
|
|
|
|
// Update view if we have one
|
|
const user = this.state.others.get(pubKey);
|
|
if (user && user.location) {
|
|
// Re-request their location at new precision
|
|
// In a real implementation, this would request updated data
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get trust level for a contact
|
|
*/
|
|
getTrustLevel(pubKey: string): TrustLevel {
|
|
return this.trustCircles.getTrustLevel(pubKey) ?? 'public';
|
|
}
|
|
|
|
/**
|
|
* Get trust circles manager
|
|
*/
|
|
getTrustCircles(): TrustCircleManager {
|
|
return this.trustCircles;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Status Management
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Set own status
|
|
*/
|
|
setStatus(status: PresenceStatus, message?: string): void {
|
|
this.state.self.status = status;
|
|
this.state.self.statusMessage = message;
|
|
this.broadcastStatus();
|
|
}
|
|
|
|
/**
|
|
* Get own status
|
|
*/
|
|
getStatus(): PresenceStatus {
|
|
return this.state.self.status;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Queries
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Get all presence views
|
|
*/
|
|
getViews(): PresenceView[] {
|
|
return Array.from(this.state.views.values());
|
|
}
|
|
|
|
/**
|
|
* Get view for a specific user
|
|
*/
|
|
getView(pubKey: string): PresenceView | undefined {
|
|
return this.state.views.get(pubKey);
|
|
}
|
|
|
|
/**
|
|
* Get all online users
|
|
*/
|
|
getOnlineUsers(): UserPresence[] {
|
|
return Array.from(this.state.others.values()).filter(
|
|
(u) => u.status === 'online' || u.status === 'away'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get users within a distance category
|
|
*/
|
|
getUsersNearby(
|
|
maxCategory: ProximityInfo['category'] = 'same-area'
|
|
): PresenceView[] {
|
|
const categories: ProximityInfo['category'][] = [
|
|
'here',
|
|
'nearby',
|
|
'same-area',
|
|
'same-city',
|
|
'far',
|
|
];
|
|
const maxIndex = categories.indexOf(maxCategory);
|
|
|
|
return Array.from(this.state.views.values()).filter((v) => {
|
|
if (!v.proximity) return false;
|
|
const viewIndex = categories.indexOf(v.proximity.category);
|
|
return viewIndex <= maxIndex;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get current state
|
|
*/
|
|
getState(): PresenceChannelState {
|
|
return this.state;
|
|
}
|
|
|
|
/**
|
|
* Get own presence
|
|
*/
|
|
getSelf(): UserPresence {
|
|
return this.state.self;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Events
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Subscribe to events
|
|
*/
|
|
on(listener: PresenceEventListener): () => void {
|
|
this.listeners.add(listener);
|
|
return () => this.listeners.delete(listener);
|
|
}
|
|
|
|
private emit(event: PresenceEvent): void {
|
|
for (const listener of this.listeners) {
|
|
try {
|
|
listener(event);
|
|
} catch (e) {
|
|
console.error('Error in presence event listener:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Utilities
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Detect device type
|
|
*/
|
|
private detectDeviceType(): UserPresence['deviceType'] {
|
|
if (typeof navigator === 'undefined') return 'unknown';
|
|
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
if (/mobile|android|iphone|ipad|ipod/.test(ua)) {
|
|
if (/ipad|tablet/.test(ua)) return 'tablet';
|
|
return 'mobile';
|
|
}
|
|
return 'desktop';
|
|
}
|
|
|
|
/**
|
|
* Get speed category from speed in m/s
|
|
*/
|
|
private getSpeedCategory(
|
|
speed?: number
|
|
): LocationBroadcastPayload['speedCategory'] {
|
|
if (speed === undefined || speed < 0.5) return 'stationary';
|
|
if (speed < 2) return 'walking';
|
|
if (speed < 8) return 'cycling';
|
|
if (speed < 50) return 'driving';
|
|
return 'flying';
|
|
}
|
|
|
|
/**
|
|
* Generate color from public key
|
|
*/
|
|
private generateUserColor(pubKey: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < pubKey.length; i++) {
|
|
hash = pubKey.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
const hue = hash % 360;
|
|
return `hsl(${hue}, 70%, 50%)`;
|
|
}
|
|
|
|
/**
|
|
* Haversine distance calculation
|
|
*/
|
|
private haversineDistance(
|
|
lat1: number,
|
|
lon1: number,
|
|
lat2: number,
|
|
lon2: number
|
|
): number {
|
|
const R = 6371000; // Earth radius in meters
|
|
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
|
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
|
const a =
|
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
Math.cos((lat1 * Math.PI) / 180) *
|
|
Math.cos((lat2 * Math.PI) / 180) *
|
|
Math.sin(dLon / 2) *
|
|
Math.sin(dLon / 2);
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
return R * c;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Factory Function
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Create a presence manager
|
|
*/
|
|
export function createPresenceManager(
|
|
config: Partial<PresenceChannelConfig> &
|
|
Pick<PresenceChannelConfig, 'channelId' | 'userPubKey' | 'userPrivKey' | 'displayName' | 'color'>
|
|
): PresenceManager {
|
|
return new PresenceManager(config);
|
|
}
|