north-star / commit
ttl for friends
src/app.ts | 27 ++++++++++++++++++++++---- src/friends-panel.ts | 47 +++++++++++++++++++++++++++++++++++++++++++-- src/renderer.ts | 15 ++++++++++++--- src/style.css | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 20 +++++++++++++++++++ 5 files changed, 154 insertions(+), 9 deletions(-) diff --git a/src/app.ts b/src/app.ts index 516b007..8fe447d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,4 @@ -import { onDisconnect, ref, set } from 'firebase/database'; +import { ref, set } from 'firebase/database'; import { getReadableTextColor, getArrowDisplayColor } from './utils'; import { encryptJson } from './crypto'; import { initDarkMode } from './darkmode'; @@ -35,6 +35,15 @@ document.head.appendChild(Object.assign(document.createElement('link'), { rel: ' const RENDER_INTERVAL_MS = 100; const LOCATION_SYNC_INTERVAL_MS = 3000; +const FRIEND_ONLINE_GRACE_MS = 20_000; +const DEFAULT_FRIEND_RECENTLY_OFFLINE_MS = 60 * 60 * 1000; +const FRIEND_RECENTLY_OFFLINE_MS = (() => { + const raw = Number(localStorage.getItem('friend-compass-recently-offline-ms')); + if (Number.isFinite(raw) && raw > FRIEND_ONLINE_GRACE_MS) { + return raw; + } + return DEFAULT_FRIEND_RECENTLY_OFFLINE_MS; +})(); const HEADING_CORRECTION_STORAGE_KEY = 'friend-compass-heading-correction-deg'; const NORTH_MARKER_ID = 'north-marker'; const GUARDIAN_ANGEL_ID = 'test'; @@ -80,7 +89,6 @@ const { roomId, roomKey } = await initSession({ }); const friendRef = ref(db, `rooms/${roomId}/friends/${uid}`); -onDisconnect(friendRef).remove(); // selfFriend is assigned below by initSelfProfile; syncSelfFriend is // only ever called after that point (from event handlers / intervals). @@ -141,7 +149,11 @@ const friendsController = createFriendsPanelController({ northMarkerId: NORTH_MARKER_ID, guardianAngelId: GUARDIAN_ANGEL_ID, selfFriend, - selfPosition + selfPosition, + presenceThresholds: { + onlineGraceMs: FRIEND_ONLINE_GRACE_MS, + recentlyOfflineMs: FRIEND_RECENTLY_OFFLINE_MS + } }); navigator.geolocation.watchPosition(async (pos) => { @@ -174,8 +186,11 @@ const { friends } = subscribeToFriends({ }); const render = () => { + const now = Date.now(); const friendsToRender = friendsController.getFriendsToRender(friends); + friendsController.refreshPresenceInPanel(now); + renderArrows({ content, arrow, @@ -183,7 +198,11 @@ const render = () => { selfFriend, selfHeading: compassController.getSelfHeading(), isFriendVisible: friendsController.isFriendVisible, - getArrowDisplayColor + getArrowDisplayColor, + presenceThresholds: { + onlineGraceMs: FRIEND_ONLINE_GRACE_MS, + recentlyOfflineMs: FRIEND_RECENTLY_OFFLINE_MS + } }); logDebug(`Commit: ${isLocalCommit ? 'local' : `<a href="https://gitlab.com/PavelSafronov/north-star/-/commit/${GIT_COMMIT}" target="_blank">${GIT_COMMIT}</a>`} | Repo: <a href="https://gitlab.com/PavelSafronov/north-star" target="_blank">north-star</a>`); diff --git a/src/friends-panel.ts b/src/friends-panel.ts index 3082328..50daa4f 100644 --- a/src/friends-panel.ts +++ b/src/friends-panel.ts @@ -1,3 +1,6 @@ +import { getFriendPresenceState, PRESENCE_EMOJI } from './utils'; +import type { PresenceThresholds } from './utils'; + export const createFriendsPanelController = ({ friendsPanel, friendsToggle, @@ -6,7 +9,18 @@ export const createFriendsPanelController = ({ northMarkerId, guardianAngelId, selfFriend, - selfPosition + selfPosition, + presenceThresholds +}: { + friendsPanel: HTMLElement | null; + friendsToggle: HTMLElement | null; + requestRender: () => void; + getReadableTextColor: (color: string) => string; + northMarkerId: string; + guardianAngelId: string; + selfFriend: any; + selfPosition: { lat: number; lon: number }; + presenceThresholds: PresenceThresholds; }) => { const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'friend-compass-friend-visibility'; const PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'friend-compass-friends-panel-visible'; @@ -287,6 +301,20 @@ export const createFriendsPanelController = ({ nameWrapper.style.backgroundColor = friend.color; nameWrapper.style.color = getReadableTextColor(friend.color); + const isNorth = friend.id === northMarkerId; + + if (!isNorth) { + const presenceStatus = document.createElement('span'); + presenceStatus.className = 'friend-presence-status'; + presenceStatus.dataset.friendId = friend.id; + const initialPresence = getFriendPresenceState(friend, Date.now(), presenceThresholds); + presenceStatus.textContent = PRESENCE_EMOJI[initialPresence] ?? '🟡'; + presenceStatus.title = initialPresence === 'online' ? 'Online' + : initialPresence === 'recent-offline' ? 'Recently seen' + : 'Offline'; + nameWrapper.appendChild(presenceStatus); + } + const nameText = document.createElement('span'); nameText.className = 'friend-name'; nameText.textContent = friend.name; @@ -334,6 +362,21 @@ export const createFriendsPanelController = ({ return { isFriendVisible, getFriendsToRender, - renderFriendControls + renderFriendControls, + refreshPresenceInPanel: (now: number) => { + if (!friendsPanel) return; + friendsPanel.querySelectorAll<HTMLElement>('.friend-presence-status[data-friend-id]').forEach((el) => { + const friendId = el.dataset.friendId; + const friend = lastFriendsToRender.find((f) => f.id === friendId); + if (!friend) return; + const state = getFriendPresenceState(friend, now, presenceThresholds); + const emoji = PRESENCE_EMOJI[state] ?? '🟡'; + if (el.textContent !== emoji) el.textContent = emoji; + const title = state === 'online' ? 'Online' + : state === 'recent-offline' ? 'Recently seen' + : 'Offline'; + if (el.title !== title) el.title = title; + }); + } }; }; diff --git a/src/renderer.ts b/src/renderer.ts index c4565a1..df1b3b4 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,4 +1,5 @@ -import { getShortestDelta, normalizeAngle } from './utils'; +import { getShortestDelta, normalizeAngle, getFriendPresenceState, PRESENCE_EMOJI } from './utils'; +import type { PresenceThresholds } from './utils'; type ArrowElement = HTMLElement & { currentAngle: number | null }; @@ -9,8 +10,10 @@ export const renderArrows = ({ selfFriend, selfHeading, isFriendVisible, - getArrowDisplayColor + getArrowDisplayColor, + presenceThresholds }) => { + const now = Date.now(); const activeArrowIds = new Set( friendsToRender .filter((target) => target.id !== selfFriend.id) @@ -65,9 +68,15 @@ export const renderArrows = ({ existingArrow.style.transform = `rotate(${currentArrowAngle}deg)`; existingArrow.style.color = getArrowDisplayColor(target.color); + const presenceState = getFriendPresenceState(target, now, presenceThresholds); + existingArrow.classList.toggle('is-online', presenceState === 'online'); + existingArrow.classList.toggle('is-recent-offline', presenceState === 'recent-offline'); + existingArrow.classList.toggle('is-stale-offline', presenceState === 'stale-offline'); + const arrowLabel = existingArrow.querySelector('.arrow-label') as HTMLElement | null; if (arrowLabel) { - arrowLabel.textContent = target.name; + const emoji = PRESENCE_EMOJI[presenceState]; + arrowLabel.textContent = emoji ? `${emoji} ${target.name}` : target.name; arrowLabel.style.transform = `rotate(${-currentArrowAngle}deg)`; } } diff --git a/src/style.css b/src/style.css index f6540ac..e892b71 100644 --- a/src/style.css +++ b/src/style.css @@ -356,6 +356,12 @@ body.dark .dark-mode-toggle { text-overflow: ellipsis; } +.friend-presence-status { + flex: 0 0 auto; + font-size: 13px; + line-height: 1; +} + .friend-map-link { font-size: 17px; line-height: 1; @@ -404,12 +410,15 @@ body.dark .dark-mode-toggle { pointer-events: none; z-index: 1; color: #ffffff; + opacity: 1; + transition: opacity 200ms ease-out, filter 220ms ease-out; } .arrow-img { width: 100%; height: 100%; background-color: currentColor; + background-image: none; -webkit-mask-image: url('./image.png'); mask-image: url('./image.png'); -webkit-mask-size: contain; @@ -419,6 +428,7 @@ body.dark .dark-mode-toggle { -webkit-mask-position: center; mask-position: center; filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.35)); + transition: opacity 220ms ease-out, filter 260ms ease-out, background-image 260ms ease-out; } .arrow-label-anchor { @@ -430,6 +440,42 @@ body.dark .dark-mode-toggle { height: 0; } +.arrow.is-online { + opacity: 1; +} + +.arrow.is-online .arrow-img { + opacity: 1; +} + +.arrow.is-recent-offline { + opacity: 0.58; +} + +.arrow.is-recent-offline .arrow-img { + opacity: 0.75; + filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.22)); +} + +.arrow.is-recent-offline .arrow-label { + opacity: 0.84; +} + +.arrow.is-stale-offline { + opacity: 0.42; +} + +.arrow.is-stale-offline .arrow-img { + opacity: 0.55; + background-color: transparent; + background-image: linear-gradient(to top, transparent 0%, transparent 28%, currentColor 68%, currentColor 100%); + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.12)); +} + +.arrow.is-stale-offline .arrow-label { + opacity: 0.6; +} + .arrow-label { position: absolute; top: 0; @@ -548,6 +594,14 @@ body.dark .arrow-img { filter: drop-shadow(0 0 2px #fff) drop-shadow(0 0 6px rgba(255, 255, 255, 0.5)); } +body.dark .arrow.is-recent-offline .arrow-img { + filter: drop-shadow(0 0 1px #fff) drop-shadow(0 0 3px rgba(255, 255, 255, 0.35)); +} + +body.dark .arrow.is-stale-offline .arrow-img { + filter: drop-shadow(0 0 1px rgba(255, 255, 255, 0.35)); +} + body.dark .arrow-label { -webkit-text-stroke: 0.25px rgba(255, 255, 255, 0.9); text-shadow: 0 1px 4px rgba(0, 0, 0, 0.95); diff --git a/src/utils.ts b/src/utils.ts index 9425f6e..113281b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -38,6 +38,26 @@ export const getArrowDisplayColor = (hexColor) => { return luminance < 0.2 ? '#ffffff' : hexColor; }; +export type PresenceThresholds = { + onlineGraceMs: number; + recentlyOfflineMs: number; +}; + +export const getFriendPresenceState = (friend, now: number, thresholds: PresenceThresholds): 'online' | 'recent-offline' | 'stale-offline' => { + const friendTimestamp = Number(friend.timestamp); + if (!Number.isFinite(friendTimestamp)) return 'stale-offline'; + const ageMs = Math.max(0, now - friendTimestamp); + if (ageMs <= thresholds.onlineGraceMs) return 'online'; + if (ageMs <= thresholds.recentlyOfflineMs) return 'recent-offline'; + return 'stale-offline'; +}; + +export const PRESENCE_EMOJI: Record<string, string> = { + online: '🟢', + 'recent-offline': '🟡', + 'stale-offline': '👻' +}; + export const getDeviceHint = async () => { if (navigator.userAgentData) { try {