Implement real-time family member location updates via ActionCable

This commit is contained in:
Eugene Burmakin
2025-10-13 14:10:36 +02:00
parent 0ee3deedd8
commit 39c3c157c8
13 changed files with 258 additions and 54 deletions

View File

@@ -4,25 +4,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
# [0.33.1]
# [0.34.0] - 2025-10-10
## The Family release
In this release we're introducing family features that allow users to create family groups, invite members, and share location data. Family owners can manage members, control sharing settings, and ensure secure access to shared information. Location sharing is optional and can be enabled or disabled by each member individually. Users can join only one family at a time. Location sharing settings can be set to share location for 1, 6, 12, 24 hours or permanently. Family features are now available only for self-hosted instances and will be available in the cloud in the future. When "Family members" layer is enabled on the map, family member markers will be updated in real-time.
## Added
- Users can now create family groups and invite members to join.
# [0.33.1] - 2025-10-07
## Changed
- On the Trip page, instead of list of visited countries, a number of them is being shown. Clicking on it opens a modal with a list of countries visited during the trip. #1731
- **Family Features**: Complete family management system allowing users to create family groups, invite members, and share location data. Features include:
- Family creation and management with role-based permissions (owner/member)
- Email-based invitation system with expiration and security controls
- Comprehensive authorization and access control via Pundit policies
- Performance-optimized database schema with concurrent indexes
- Feature gating for cloud vs self-hosted deployments
- Background job processing for email delivery and cleanup
- Interactive UI with real-time form validation and animated feedback
- Complete test coverage including unit, integration, and system tests
- Comprehensive error handling with user-friendly messages
- Full API documentation and deployment guides
## Fixed
- `GET /api/v1/stats` endpoint now returns correct 0 instead of null if no points were tracked in the requested period.

View File

@@ -163,4 +163,27 @@
.leaflet-popup-content-wrapper:has(.family-member-popup) + .leaflet-popup-tip {
background-color: #1f2937 !important;
}
/* Family member marker pulse animation for recent updates */
@keyframes family-marker-pulse {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
}
50% {
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
.family-member-marker-recent {
animation: family-marker-pulse 2s infinite;
border-radius: 50% !important;
}
.family-member-marker-recent .leaflet-marker-icon > div {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2), 0 0 0 0 rgba(16, 185, 129, 0.7);
border-radius: 50%;
}

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
class FamilyLocationsChannel < ApplicationCable::Channel
def subscribed
return reject unless family_feature_enabled?
return reject unless current_user.in_family?
stream_for current_user.family
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
private
def family_feature_enabled?
DawarichSettings.family_feature_enabled?
end
end

View File

@@ -0,0 +1,24 @@
import consumer from "./consumer"
// Only create subscription if family feature is enabled
const familyFeaturesElement = document.querySelector('[data-family-members-features-value]');
const features = familyFeaturesElement ? JSON.parse(familyFeaturesElement.dataset.familyMembersFeaturesValue) : {};
if (features.family) {
consumer.subscriptions.create("FamilyLocationsChannel", {
connected() {
// Connected to family locations channel
},
disconnected() {
// Disconnected from family locations channel
},
received(data) {
// Pass data to family members controller if it exists
if (window.familyMembersController) {
window.familyMembersController.updateSingleMemberLocation(data);
}
}
});
}

View File

@@ -2,3 +2,4 @@
import "notifications_channel"
import "points_channel"
import "imports_channel"
import "family_locations_channel"

View File

@@ -52,7 +52,11 @@ export default class extends Controller {
// Initialize family member markers layer
this.familyMarkersLayer = L.layerGroup();
this.familyMemberLocations = []; // Initialize as empty, will be fetched via API
this.familyMemberLocations = {}; // Object keyed by user_id for efficient updates
this.familyMarkers = {}; // Store marker references by user_id
// Expose controller globally for ActionCable channel
window.familyMembersController = this;
// Add to layer control immediately (layer will be empty until data is fetched)
this.addToLayerControl();
@@ -67,16 +71,19 @@ export default class extends Controller {
this.familyMarkersLayer.clearLayers();
}
// Clear marker references
this.familyMarkers = {};
// Only proceed if family feature is enabled and we have family member locations
if (!this.featuresValue.family ||
!this.familyMemberLocations ||
this.familyMemberLocations.length === 0) {
Object.keys(this.familyMemberLocations).length === 0) {
return;
}
const bounds = [];
this.familyMemberLocations.forEach((location) => {
Object.values(this.familyMemberLocations).forEach((location) => {
if (!location || !location.latitude || !location.longitude) {
return;
}
@@ -84,13 +91,17 @@ export default class extends Controller {
// Get the first letter of the email or use '?' as fallback
const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?';
// Check if this is a recent update (within last 5 minutes)
const isRecent = this.isRecentUpdate(location.updated_at);
const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker';
// Create a distinct marker for family members with email initial
const familyMarker = L.marker([location.latitude, location.longitude], {
icon: L.divIcon({
html: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
className: 'family-member-marker'
className: markerClass
})
});
@@ -120,6 +131,9 @@ export default class extends Controller {
this.familyMarkersLayer.addLayer(familyMarker);
// Store marker reference by user_id for efficient updates
this.familyMarkers[location.user_id] = familyMarker;
// Add to bounds array for auto-zoom
bounds.push([location.latitude, location.longitude]);
});
@@ -128,6 +142,102 @@ export default class extends Controller {
this.familyMemberBounds = bounds;
}
// Update a single family member's location in real-time
updateSingleMemberLocation(locationData) {
if (!this.featuresValue.family) return;
if (!locationData || !locationData.user_id) return;
// Update stored location data
this.familyMemberLocations[locationData.user_id] = locationData;
// If the Family Members layer is not currently visible, just store the data
if (!this.map.hasLayer(this.familyMarkersLayer)) {
return;
}
// Get existing marker for this user
const existingMarker = this.familyMarkers[locationData.user_id];
if (existingMarker) {
// Update existing marker position and content
existingMarker.setLatLng([locationData.latitude, locationData.longitude]);
// Update marker icon with pulse animation for recent updates
const emailInitial = locationData.email_initial || locationData.email?.charAt(0)?.toUpperCase() || '?';
const isRecent = this.isRecentUpdate(locationData.updated_at);
const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker';
const newIcon = L.divIcon({
html: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
className: markerClass
});
existingMarker.setIcon(newIcon);
// Update tooltip content
const lastSeen = new Date(locationData.updated_at).toLocaleString();
const tooltipContent = this.createTooltipContent(lastSeen);
existingMarker.setTooltipContent(tooltipContent);
// Update popup content
const popupContent = this.createPopupContent(locationData, lastSeen);
existingMarker.setPopupContent(popupContent);
} else {
// Create new marker for this user
this.createSingleFamilyMarker(locationData);
}
}
// Check if location was updated within the last 5 minutes
isRecentUpdate(updatedAt) {
const updateTime = new Date(updatedAt);
const now = new Date();
const diffMinutes = (now - updateTime) / 1000 / 60;
return diffMinutes < 5;
}
// Create a marker for a single family member
createSingleFamilyMarker(location) {
if (!location || !location.latitude || !location.longitude) return;
const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?';
const isRecent = this.isRecentUpdate(location.updated_at);
const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker';
const familyMarker = L.marker([location.latitude, location.longitude], {
icon: L.divIcon({
html: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
className: markerClass
})
});
const lastSeen = new Date(location.updated_at).toLocaleString();
const tooltipContent = this.createTooltipContent(lastSeen);
familyMarker.bindTooltip(tooltipContent, {
permanent: true,
direction: 'top',
offset: [0, -12],
className: 'family-member-tooltip'
});
const popupContent = this.createPopupContent(location, lastSeen);
familyMarker.bindPopup(popupContent);
familyMarker.on('popupopen', () => {
familyMarker.closeTooltip();
});
familyMarker.on('popupclose', () => {
familyMarker.openTooltip();
});
this.familyMarkersLayer.addLayer(familyMarker);
this.familyMarkers[location.user_id] = familyMarker;
}
createTooltipContent(lastSeen) {
return `Last updated: ${lastSeen}`;
}
@@ -202,11 +312,10 @@ export default class extends Controller {
// Listen for when the Family Members layer is added
this.map.on('overlayadd', (event) => {
if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) {
console.log('Family Members layer enabled - refreshing locations and zooming to fit');
this.refreshFamilyLocations();
// Zoom to show all family members
this.zoomToFitAllMembers();
// Refresh locations and zoom after data is loaded
this.refreshFamilyLocations().then(() => {
this.zoomToFitAllMembers();
});
// Set up periodic refresh while layer is active
this.startPeriodicRefresh();
@@ -245,7 +354,7 @@ export default class extends Controller {
// Clear any existing refresh interval
this.stopPeriodicRefresh();
// Refresh family locations every 30 seconds while layer is active
// Refresh family locations every 60 seconds while layer is active (as fallback to real-time)
this.refreshInterval = setInterval(() => {
if (this.map && this.map.hasLayer(this.familyMarkersLayer)) {
this.refreshFamilyLocations();
@@ -253,7 +362,7 @@ export default class extends Controller {
// Layer is no longer active, stop refreshing
this.stopPeriodicRefresh();
}
}, 30000); // 30 seconds
}, 60000); // 60 seconds (real-time updates via ActionCable are primary)
}
stopPeriodicRefresh() {
@@ -265,12 +374,23 @@ export default class extends Controller {
// Method to manually update family member locations (for API calls)
updateFamilyLocations(locations) {
this.familyMemberLocations = locations;
// Convert array to object keyed by user_id
if (Array.isArray(locations)) {
this.familyMemberLocations = {};
locations.forEach(location => {
if (location.user_id) {
this.familyMemberLocations[location.user_id] = location;
}
});
} else {
this.familyMemberLocations = locations;
}
this.createFamilyMarkers();
// Dispatch event for other controllers that might be interested
document.dispatchEvent(new CustomEvent('family:locations:updated', {
detail: { locations: locations }
detail: { locations: this.familyMemberLocations }
}));
}
@@ -361,6 +481,6 @@ export default class extends Controller {
// Get family marker count
getFamilyMemberCount() {
return this.familyMemberLocations ? this.familyMemberLocations.length : 0;
return this.familyMemberLocations ? Object.keys(this.familyMemberLocations).length : 0;
}
}

View File

@@ -75,24 +75,49 @@ class Point < ApplicationRecord
# rubocop:disable Metrics/MethodLength Metrics/AbcSize
def broadcast_coordinates
return unless user.safe_settings.live_map_enabled
if user.safe_settings.live_map_enabled
PointsChannel.broadcast_to(
user,
[
lat,
lon,
battery.to_s,
altitude.to_s,
timestamp.to_s,
velocity.to_s,
id.to_s,
country_name.to_s
]
)
end
PointsChannel.broadcast_to(
user,
[
lat,
lon,
battery.to_s,
altitude.to_s,
timestamp.to_s,
velocity.to_s,
id.to_s,
country_name.to_s
]
)
broadcast_to_family if should_broadcast_to_family?
end
# rubocop:enable Metrics/MethodLength
def should_broadcast_to_family?
return false unless DawarichSettings.family_feature_enabled?
return false unless user.in_family?
return false unless user.family_sharing_enabled?
true
end
def broadcast_to_family
FamilyLocationsChannel.broadcast_to(
user.family,
{
user_id: user.id,
email: user.email,
email_initial: user.email.first.upcase,
latitude: lat,
longitude: lon,
timestamp: timestamp.to_i,
updated_at: Time.zone.at(timestamp.to_i).iso8601
}
)
end
def set_country
self.country_id = found_in_country&.id
save! if changed?

View File

@@ -89,18 +89,16 @@ module Families
Notification.create!(
user: user,
kind: :info,
title: 'Welcome to Family',
title: 'Welcome to Family!',
content: "You've joined the family '#{invitation.family.name}'"
)
end
def send_owner_notification
return unless defined?(Notification)
Notification.create!(
user: invitation.family.creator,
kind: :info,
title: 'New Family Member',
title: 'New Family Member!',
content: "#{user.email} has joined your family"
)
rescue StandardError => e

View File

@@ -94,8 +94,6 @@ module Families
end
def send_notification
return unless defined?(Notification)
Notification.create!(
user: user,
kind: :info,

View File

@@ -90,8 +90,6 @@ module Families
end
def send_notification
return unless defined?(Notification)
Notification.create!(
user: invited_by,
kind: :info,

View File

@@ -96,8 +96,6 @@ module Families
end
def send_notifications
return unless defined?(Notification)
if removing_self?
send_self_removal_notifications
else

View File

@@ -23,5 +23,6 @@ pin 'leaflet-draw' # @1.0.4
pin 'notifications_channel', to: 'channels/notifications_channel.js'
pin 'points_channel', to: 'channels/points_channel.js'
pin 'imports_channel', to: 'channels/imports_channel.js'
pin 'family_locations_channel', to: 'channels/family_locations_channel.js'
pin 'trix'
pin '@rails/actiontext', to: 'actiontext.esm.js'

View File

@@ -26,10 +26,10 @@ RSpec.describe Families::AcceptInvitation do
it 'sends notifications to both parties' do
expect { service.call }.to change(Notification, :count).by(2)
user_notification = Notification.find_by(user: invitee, title: 'Welcome to Family')
user_notification = Notification.find_by(user: invitee, title: 'Welcome to Family!')
expect(user_notification).to be_present
owner_notification = Notification.find_by(user: family.creator, title: 'New Family Member')
owner_notification = Notification.find_by(user: family.creator, title: 'New Family Member!')
expect(owner_notification).to be_present
end