ADD - implement CrewLink location groups

This commit is contained in:
smx.pusha
2026-08-10 21:02:09 +02:00
parent a2e227d624
commit db623e73e6
20 changed files with 3866 additions and 0 deletions
+32
View File
@@ -185,6 +185,16 @@ type BillingNotificationData = {
text?: string
title?: string
}
type CrewLinkNotificationData = {
actor?: string
device?: PhoneNotificationDevicePayload
groupName?: string
kind?: 'invite' | 'member_joined' | 'ping' | 'role' | 'removed'
pingLabel?: string
text?: string
title?: string
}
const REFERENCE_VIEWPORT_WIDTH = 1920
const REFERENCE_VIEWPORT_HEIGHT = 1080
const PHONE_BASE_SCALE = 0.69
@@ -656,6 +666,28 @@ function onMessage(event: MessageEvent<AppMessage>): void {
}
}
notifications.show(notification)
} else if (
event.data?.type === 'crewlink:notification' &&
event.data.data
) {
const data = event.data.data as CrewLinkNotificationData
const notification: PhoneNotificationInput = {
appId: 'crewlink',
subtitle: data.groupName,
text: data.text ?? phone.t('Apps.crewlink.notifications.default'),
title: data.title ?? phone.t('Apps.crewlink.name'),
}
if (
data.device &&
(!phone.isOpen || data.device.imei !== phone.device?.imei)
) {
notification.device = {
imei: data.device.imei,
name: data.device.name,
preferences: parsePhonePreferences(data.device.settings ?? null),
}
}
notifications.show(notification)
} else if (
(event.data?.type === 'call:incoming' ||
event.data?.type === 'call:state') &&
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="CrewLink">
<defs>
<linearGradient id="crewlink-bg" x1="16" y1="12" x2="112" y2="118" gradientUnits="userSpaceOnUse">
<stop stop-color="#24e2f2"/>
<stop offset="0.52" stop-color="#0a9bf5"/>
<stop offset="1" stop-color="#5746e8"/>
</linearGradient>
<filter id="crewlink-glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="3.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<rect width="128" height="128" rx="29" fill="url(#crewlink-bg)"/>
<path d="M20 78c8-29 24-44 44-44s36 15 44 44" fill="none" stroke="#fff" stroke-opacity=".25" stroke-width="7" stroke-linecap="round"/>
<path d="M31 84c7-21 18-31 33-31s26 10 33 31" fill="none" stroke="#fff" stroke-opacity=".45" stroke-width="7" stroke-linecap="round"/>
<circle cx="64" cy="78" r="13" fill="#fff" filter="url(#crewlink-glow)"/>
<circle cx="34" cy="86" r="10" fill="#d8fbff"/>
<circle cx="94" cy="86" r="10" fill="#d8fbff"/>
<path d="M64 102c-11-11-18-20-18-31a18 18 0 1 1 36 0c0 11-7 20-18 31Z" fill="#fff" fill-opacity=".18" stroke="#fff" stroke-width="3"/>
<circle cx="64" cy="71" r="5" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -150,6 +150,7 @@ describe('app registry', () => {
'flare',
'radio',
'local-pages',
'crewlink',
'phone',
'darkchat',
'banking',
+16
View File
@@ -31,6 +31,7 @@ import {
Music2,
Feather,
ReceiptText,
UsersRound,
} from 'lucide-vue-next'
import { defineAsyncComponent, markRaw } from 'vue'
@@ -68,6 +69,7 @@ import picstagramIcon from '@/assets/img/app-icons/picstagram.webp'
import skyRideIcon from '@/assets/img/app-icons/skyride.svg'
import musicIcon from '@/assets/img/app-icons/music.svg'
import featherIcon from '@/assets/img/app-icons/feather.svg'
import crewLinkIcon from '@/assets/img/app-icons/crewlink.svg'
import type {
LaunchablePhoneAppDefinition,
LaunchablePhoneAppId,
@@ -187,6 +189,20 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
labelKey: 'Apps.localPages.name',
route: '/apps/local-pages',
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CrewLinkApp.vue')),
),
dockOrder: null,
gridOrder: 27,
icon: markRaw(UsersRound),
iconClass: 'app-icon--crewlink',
iconImage: crewLinkIcon,
id: 'crewlink',
labelKey: 'Apps.crewlink.name',
route: '/apps/crewlink',
},
{
category: 'social',
component: markRaw(
+96
View File
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
}))
import { useCrewLinkStore } from '@/stores/crewlink'
import { nuiCall } from '@/utils/nui'
describe('CrewLink store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(nuiCall).mockReset()
})
it('hydrates a profile and active group from bootstrap', async () => {
vi.mocked(nuiCall).mockResolvedValue({
success: true,
data: {
activeGroup: {
allowMemberPings: true,
colour: 'cyan',
id: 'group-1',
isOwner: true,
memberCount: 1,
members: [],
name: 'Night Shift',
overheadAllowed: true,
pings: [],
role: 'owner',
},
groups: [],
invitations: [],
profile: {
activeGroupId: 'group-1',
id: 'profile-1',
mapVisible: true,
overheadVisible: false,
username: 'Skyline',
},
},
})
const store = useCrewLinkStore()
expect(await store.bootstrap()).toBe(true)
expect(store.profile?.username).toBe('Skyline')
expect(store.activeGroup?.name).toBe('Night Shift')
})
it('keeps the server error from a rejected request', async () => {
vi.mocked(nuiCall).mockResolvedValue({
success: false,
error: 'invalid_code',
})
const store = useCrewLinkStore()
const response = await store.joinCode('BADCODE')
expect(response.success).toBe(false)
expect(store.error).toBe('invalid_code')
})
it('applies live members without replacing group metadata', async () => {
const store = useCrewLinkStore()
store.activeGroup = {
allowMemberPings: true,
colour: 'blue',
id: 'group-1',
isOwner: false,
memberCount: 2,
members: [],
name: 'Road Crew',
overheadAllowed: true,
pings: [],
role: 'member',
}
vi.mocked(nuiCall).mockResolvedValue({
success: true,
data: {
members: [
{
id: 'profile-2',
joinedAt: 1,
mapVisible: true,
online: true,
overheadVisible: true,
role: 'member',
username: 'Nova',
},
],
pings: [],
},
})
expect(await store.refreshLive()).toBe(true)
expect(store.activeGroup.name).toBe('Road Crew')
expect(store.activeGroup.members[0].online).toBe(true)
})
})
+180
View File
@@ -0,0 +1,180 @@
import { defineStore } from 'pinia'
import type {
CrewLinkBootstrap,
CrewLinkColour,
CrewLinkGroup,
CrewLinkLive,
CrewLinkNearbyPlayer,
CrewLinkPing,
CrewLinkPingType,
CrewLinkRole,
} from '@/types/crewlink'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const useCrewLinkStore = defineStore('crewlink', {
state: () => ({
activeGroup: null as CrewLinkGroup | null,
error: '',
groups: [] as CrewLinkBootstrap['groups'],
invitations: [] as CrewLinkBootstrap['invitations'],
isLoading: false,
limits: null as CrewLinkBootstrap['limits'] | null,
profile: null as CrewLinkBootstrap['profile'],
}),
actions: {
applyBootstrap(data: CrewLinkBootstrap): void {
this.profile = data.profile
this.groups = data.groups ?? []
this.activeGroup = data.activeGroup ?? null
this.invitations = data.invitations ?? []
this.limits = data.limits ?? null
this.error = ''
},
async bootstrap(): Promise<boolean> {
this.isLoading = true
const response = await nuiCall<CrewLinkBootstrap>('crewlink:bootstrap')
this.isLoading = false
if (response.success && response.data) {
this.applyBootstrap(response.data)
return true
}
this.error = response.error ?? 'request_failed'
return false
},
async request(
endpoint: string,
data: Record<string, unknown> = {},
refresh = true,
): Promise<NuiResponse<CrewLinkBootstrap>> {
this.isLoading = true
const response = await nuiCall<CrewLinkBootstrap>(endpoint, data)
this.isLoading = false
if (!response.success) {
this.error = response.error ?? 'request_failed'
return response
}
this.error = ''
if (response.data?.profile !== undefined) {
this.applyBootstrap(response.data)
} else if (refresh) {
await this.bootstrap()
}
return response
},
createProfile(username: string): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:create-profile', { username })
},
updateProfile(
username: string,
mapVisible: boolean,
overheadVisible: boolean,
): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:update-profile', {
mapVisible,
overheadVisible,
username,
})
},
createGroup(
name: string,
colour: CrewLinkColour,
): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:create-group', { colour, name })
},
updateGroup(
groupId: string,
name: string,
colour: CrewLinkColour,
allowMemberPings: boolean,
overheadAllowed: boolean,
): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:update-group', {
allowMemberPings,
colour,
groupId,
name,
overheadAllowed,
})
},
deleteGroup(groupId: string): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:delete-group', { groupId })
},
setActive(groupId: string): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:set-active', { groupId })
},
joinCode(code: string): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:join-code', { code })
},
rotateCode(groupId: string): Promise<NuiResponse<{ inviteCode: string }>> {
return nuiCall<{ inviteCode: string }>('crewlink:rotate-code', { groupId })
},
nearby(): Promise<NuiResponse<CrewLinkNearbyPlayer[]>> {
return nuiCall<CrewLinkNearbyPlayer[]>('crewlink:nearby')
},
inviteNearby(targetSource: number): Promise<NuiResponse> {
return this.request(
'crewlink:invite-nearby',
{ targetSource },
false,
)
},
respondInvite(
invitationId: string,
accepted: boolean,
): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:respond-invite', {
accepted,
invitationId,
})
},
updateMember(
groupId: string,
profileId: string,
role: CrewLinkRole,
): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:update-member', {
groupId,
profileId,
role,
})
},
transferOwner(
groupId: string,
profileId: string,
): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:transfer-owner', { groupId, profileId })
},
removeMember(
groupId: string,
profileId: string,
): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:remove-member', { groupId, profileId })
},
leave(groupId: string): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:leave', { groupId })
},
createPing(
type: CrewLinkPingType,
label: string,
coords?: { x: number; y: number; z: number },
): Promise<NuiResponse<CrewLinkPing>> {
return nuiCall<CrewLinkPing>('crewlink:create-ping', {
coords,
label,
type,
useCurrent: !coords,
})
},
removePing(pingId: string): Promise<NuiResponse> {
return this.request('crewlink:remove-ping', { pingId })
},
async refreshLive(): Promise<boolean> {
const response = await nuiCall<CrewLinkLive>('crewlink:live')
if (!response.success || !response.data || !this.activeGroup) return false
this.activeGroup.members = response.data.members
this.activeGroup.pings = response.data.pings
return true
},
},
})
+199
View File
@@ -40,6 +40,205 @@ const namespaceQueues = new Map<string, Promise<void>>()
const defaultLocales: LocaleTree = {
Apps: {
crewlink: {
name: 'CrewLink',
connecting: 'Connecting your crew...',
privateNetwork: 'Private location network',
signInTitle: 'Connect your iFruit Account',
signInBody:
'CrewLink uses your private iFruit identity to keep groups and roles available across your phones.',
openSettings: 'Open iFruit Settings',
welcomeEyebrow: 'Your crew. One signal.',
welcomeTitle: 'Find your people',
welcomeBody:
'Choose a private CrewLink username. Only confirmed group members can see your presence and shared location.',
username: 'CrewLink username',
usernamePlaceholder: 'e.g. Skyline',
createProfile: 'Create CrewLink Profile',
profileCreated: 'Your CrewLink profile is ready.',
privacyNote: 'Private by design. No public player search.',
noGroupEyebrow: 'No active crew',
noGroupTitle: 'Build your private network',
noGroupBody:
'Create a crew or join friends with a private invitation code.',
createGroup: 'Create Group',
createGroupBody:
'Give your crew a recognizable name and signal colour.',
joinGroup: 'Join Group',
joinWithCode: 'Join with Code',
joinWithCodeBody:
'Enter the private eight-character code shared by a coordinator.',
groupName: 'Group name',
groupNamePlaceholder: 'e.g. Night Shift',
groupColour: 'Signal colour',
createCrew: 'Create Crew',
joinCrew: 'Join Crew',
inviteCode: 'Invitation code',
inviteCodePlaceholder: 'AB12CD34',
groupCreated: 'Crew created.',
joinedGroup: 'You joined the crew.',
activeGroupChanged: 'Active crew changed.',
pendingInvitations: 'Invitations',
invitedBy: 'Invited by @{username}',
inviteAccepted: 'Invitation accepted.',
inviteDeclined: 'Invitation declined.',
navigation: 'CrewLink navigation',
map: 'Map',
crew: 'Crew',
pings: 'Pings',
profile: 'Profile',
onlineNow: 'online now',
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
myLocation: 'Center my location',
member: 'Member',
members: 'Members',
openCrew: 'View team',
newPing: 'New Ping',
shareLocation: 'Mark a place',
locationUnavailable: 'Your live location is currently hidden.',
activeCrew: 'Active private crew',
groupSummarySingle: '{online} of {total} member online',
groupSummary: '{online} of {total} members online',
private: 'Private',
nearby: 'Nearby',
copyCode: 'Copy code',
manage: 'Manage',
refresh: 'Refresh',
codeCopied: 'Invitation code copied.',
codeRotated: 'A new invitation code is active.',
peopleNearby: 'People Nearby',
peopleNearbyBody:
'Only CrewLink users within {distance} meters appear here.',
metersAway: '{distance} m away',
invite: 'Invite',
inviteSent: 'Invitation sent to @{username}.',
nobodyNearby: 'Nobody in range',
nobodyNearbyBody:
'Move closer to another CrewLink user and scan again.',
scanAgain: 'Scan Again',
liveCoordination: 'Live coordination',
noPings: 'No active pings',
noPingsBody: 'Share a meeting point, warning, or target with your crew.',
expiresMinutes: '{count} min left',
expiresSeconds: '{count} sec left',
sharedBy: 'Shared by @{username}',
setRoute: 'Set Route',
routeSet: 'GPS route set.',
pingCreated: 'Ping shared with your crew.',
pingRemoved: 'Ping removed.',
newPingBody:
'Share your current position or place the ping at the center of the map.',
pingLabel: 'Ping label',
pingLabelPlaceholder: 'e.g. Meet at the garage',
placeOnMap: 'Use map center',
placeOnMapBody: 'Otherwise your current position is used.',
sharePing: 'Share Ping',
pingTypes: {
meeting: 'Meet',
danger: 'Danger',
help: 'Help',
target: 'Target',
info: 'Info',
},
status: { live: 'Live', hidden: 'Location hidden', offline: 'Offline' },
roles: {
owner: 'Owner',
coordinator: 'Coordinator',
moderator: 'Moderator',
member: 'Member',
guest: 'Guest',
},
roleDescriptions: {
coordinator: 'Manage invitations, settings, roles, and pings.',
moderator: 'Invite nearby users and moderate group pings.',
member: 'Share presence and create pings when enabled.',
guest: 'View the active crew without coordination permissions.',
},
manageCrew: 'Manage Crew',
manageCrewBody: 'Update identity, permissions, and invitations.',
memberPings: 'Member pings',
memberPingsBody: 'Allow members and guests to create pings.',
allowOverhead: 'Overhead labels',
allowOverheadBody: 'Permit nearby opt-in member labels in the world.',
groupSaved: 'Crew settings saved.',
assignRole: 'Assign Role',
roleUpdated: 'Member role updated.',
transferOwnership: 'Transfer Ownership',
removeMember: 'Remove from Crew',
yourCrewLinkId: 'Your CrewLink ID',
privacyVisibility: 'Privacy & Visibility',
shareOnMap: 'Share on group map',
shareOnMapBody: 'Active members can see your live position.',
overheadLabels: 'Nearby overhead label',
overheadLabelsBody: 'Show your CrewLink name to nearby opted-in members.',
yourGroups: 'Your Groups',
createAnotherGroup: 'Create another group',
account: 'CrewLink Account',
externalApi: 'Connected resources',
externalApiBody: 'Approved scripts may add temporary group pings.',
editUsername: 'Edit Username',
editUsernameBody: 'Your username is unique across CrewLink.',
profileSaved: 'CrewLink profile saved.',
deleteGroup: 'Delete Group',
leaveGroup: 'Leave Group',
confirmAction: 'Confirm',
confirm: {
'delete-group': {
title: 'Delete this crew?',
body: 'The group, memberships, invitations, and active pings will be permanently removed.',
},
'leave-group': {
title: 'Leave this crew?',
body: 'You will lose access to its members, map, and pings.',
},
'remove-member': {
title: 'Remove this member?',
body: 'They will immediately lose access to this crew.',
},
'transfer-owner': {
title: 'Transfer ownership?',
body: 'The selected member becomes owner and you become coordinator.',
},
},
'delete-groupDone': 'Crew deleted.',
'leave-groupDone': 'You left the crew.',
'remove-memberDone': 'Member removed.',
'transfer-ownerDone': 'Ownership transferred.',
notifications: {
invite: '{actor} invited you to {group}.',
member_joined: '{actor} joined {group}.',
ping: '{actor} shared “{ping}” with {group}.',
role: 'Your CrewLink role changed in {group}.',
removed: 'You were removed from {group}.',
default: 'Your CrewLink group has an update.',
},
errors: {
not_authenticated: 'Sign in to your iFruit Account first.',
profile_required: 'Create your CrewLink profile first.',
invalid_username: 'Use 320 letters, numbers, dots, or underscores.',
invalid_profile: 'Check your profile details.',
username_taken: 'That CrewLink username is already taken.',
invalid_group: 'Choose a valid group name and signal colour.',
group_limit: 'You have reached your group limit.',
member_limit: 'This group has reached its member limit.',
group_not_found: 'This group is no longer available.',
invalid_code: 'That invitation code is invalid.',
already_member: 'You are already a member of this group.',
forbidden: 'Your current role cannot perform this action.',
player_not_nearby: 'That player is no longer nearby.',
player_unavailable: 'That player cannot receive a CrewLink invitation.',
invalid_invitation: 'This invitation is invalid.',
invitation_expired: 'This invitation has expired.',
invalid_role: 'That role cannot be assigned.',
owner_must_transfer: 'Transfer ownership before leaving this group.',
invalid_ping: 'Choose a valid ping type, label, and position.',
ping_limit: 'This crew already has too many active pings.',
ping_not_found: 'That ping is no longer active.',
rate_limited: 'Too many CrewLink actions. Try again shortly.',
request_failed: 'CrewLink is temporarily unavailable.',
},
},
flare: {
name: 'Flare',
signInTitle: 'Sign in to Flare',
+1
View File
@@ -35,6 +35,7 @@ export type PhoneAppId =
| 'picstagram'
| 'skyride'
| 'feather'
| 'crewlink'
export type LaunchablePhoneAppId = PhoneAppId
+108
View File
@@ -0,0 +1,108 @@
import type { MapPoint } from '@/features/map/defaultMapGeometry'
export type CrewLinkRole =
| 'owner'
| 'coordinator'
| 'moderator'
| 'member'
| 'guest'
export type CrewLinkColour =
| 'cyan'
| 'blue'
| 'violet'
| 'orange'
| 'green'
| 'rose'
export type CrewLinkPingType =
| 'meeting'
| 'danger'
| 'help'
| 'target'
| 'info'
export type CrewLinkProfile = {
activeGroupId: string | null
id: string
mapVisible: boolean
overheadVisible: boolean
username: string
}
export type CrewLinkMember = {
coords?: MapPoint & { z: number }
id: string
joinedAt: number
mapVisible: boolean
online: boolean
overheadVisible: boolean
role: CrewLinkRole
source?: number
username: string
}
export type CrewLinkPing = {
coords: MapPoint & { z: number }
createdAt: number
creatorProfileId: string | null
creatorUsername: string
expiresAt: number
id: string
label: string
sourceResource?: string | null
type: CrewLinkPingType
}
export type CrewLinkGroupSummary = {
allowMemberPings: boolean
colour: CrewLinkColour
id: string
inviteCode?: string
isOwner: boolean
memberCount: number
name: string
overheadAllowed: boolean
role: CrewLinkRole
}
export type CrewLinkGroup = CrewLinkGroupSummary & {
members: CrewLinkMember[]
pings: CrewLinkPing[]
}
export type CrewLinkInvitation = {
colour: CrewLinkColour
expiresAt: number
groupId: string
groupName: string
id: string
inviterUsername: string
}
export type CrewLinkLimits = {
maximumGroups: number
maximumMembers: number
nearbyDistance: number
overheadDistance: number
pingLifetimeSeconds: number
}
export type CrewLinkBootstrap = {
activeGroup?: CrewLinkGroup | null
groups: CrewLinkGroupSummary[]
invitations: CrewLinkInvitation[]
limits?: CrewLinkLimits
profile: CrewLinkProfile | null
}
export type CrewLinkNearbyPlayer = {
distance: number
source: number
username: string
}
export type CrewLinkLive = {
members: CrewLinkMember[]
pings: CrewLinkPing[]
}
+1
View File
@@ -84,6 +84,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
picstagram: { enabled: true, sounds: true },
fliptok: { enabled: true, sounds: true },
feather: { enabled: true, sounds: true },
crewlink: { enabled: true, sounds: true },
camera: { enabled: true, sounds: true },
clock: { enabled: true, sounds: true },
calendar: { enabled: true, sounds: true },
File diff suppressed because it is too large Load Diff