diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5d27f1..690fea7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/assets/stylesheets/leaflet_theme.css b/app/assets/stylesheets/leaflet_theme.css index aee5c6b7..09c77b51 100644 --- a/app/assets/stylesheets/leaflet_theme.css +++ b/app/assets/stylesheets/leaflet_theme.css @@ -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%; } \ No newline at end of file diff --git a/app/channels/family_locations_channel.rb b/app/channels/family_locations_channel.rb new file mode 100644 index 00000000..4520d3af --- /dev/null +++ b/app/channels/family_locations_channel.rb @@ -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 diff --git a/app/javascript/channels/family_locations_channel.js b/app/javascript/channels/family_locations_channel.js new file mode 100644 index 00000000..bdcf330a --- /dev/null +++ b/app/javascript/channels/family_locations_channel.js @@ -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); + } + } + }); +} diff --git a/app/javascript/channels/index.js b/app/javascript/channels/index.js index 0c2237ee..382a0dcc 100644 --- a/app/javascript/channels/index.js +++ b/app/javascript/channels/index.js @@ -2,3 +2,4 @@ import "notifications_channel" import "points_channel" import "imports_channel" +import "family_locations_channel" diff --git a/app/javascript/controllers/family_members_controller.js b/app/javascript/controllers/family_members_controller.js index 989509f4..b77a9273 100644 --- a/app/javascript/controllers/family_members_controller.js +++ b/app/javascript/controllers/family_members_controller.js @@ -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: `
${emailInitial}
`, 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: `
${emailInitial}
`, + 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: `
${emailInitial}
`, + 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; } } \ No newline at end of file diff --git a/app/models/point.rb b/app/models/point.rb index 2f1b9fef..b19e828d 100644 --- a/app/models/point.rb +++ b/app/models/point.rb @@ -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? diff --git a/app/services/families/accept_invitation.rb b/app/services/families/accept_invitation.rb index a270cdd0..3e327a43 100644 --- a/app/services/families/accept_invitation.rb +++ b/app/services/families/accept_invitation.rb @@ -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 diff --git a/app/services/families/create.rb b/app/services/families/create.rb index 6cd56e21..08135f99 100644 --- a/app/services/families/create.rb +++ b/app/services/families/create.rb @@ -94,8 +94,6 @@ module Families end def send_notification - return unless defined?(Notification) - Notification.create!( user: user, kind: :info, diff --git a/app/services/families/invite.rb b/app/services/families/invite.rb index b288c921..c1d7796b 100644 --- a/app/services/families/invite.rb +++ b/app/services/families/invite.rb @@ -90,8 +90,6 @@ module Families end def send_notification - return unless defined?(Notification) - Notification.create!( user: invited_by, kind: :info, diff --git a/app/services/families/memberships/destroy.rb b/app/services/families/memberships/destroy.rb index cd7672c4..efdbc914 100644 --- a/app/services/families/memberships/destroy.rb +++ b/app/services/families/memberships/destroy.rb @@ -96,8 +96,6 @@ module Families end def send_notifications - return unless defined?(Notification) - if removing_self? send_self_removal_notifications else diff --git a/config/importmap.rb b/config/importmap.rb index badf814e..53ca7e84 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -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' diff --git a/spec/services/families/accept_invitation_spec.rb b/spec/services/families/accept_invitation_spec.rb index bf947b4a..28dca538 100644 --- a/spec/services/families/accept_invitation_spec.rb +++ b/spec/services/families/accept_invitation_spec.rb @@ -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