ADD - merge CrewLink app

This commit is contained in:
smx.pusha
2026-08-10 21:02:50 +02:00
20 changed files with 3866 additions and 0 deletions
+32
View File
@@ -186,6 +186,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
@@ -657,6 +667,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
+491
View File
@@ -142,6 +142,243 @@ let mockMapMarkers = [
label: 'Meeting point',
},
]
let crewLinkProfile = {
activeGroupId: 'crewlink-group-night-shift',
id: 'crewlink-profile-skyline',
mapVisible: true,
overheadVisible: false,
username: 'Skyline',
}
let crewLinkGroups = [
{
allowMemberPings: true,
colour: 'cyan',
id: 'crewlink-group-night-shift',
inviteCode: 'N1GHT247',
isOwner: true,
memberCount: 6,
name: 'Night Shift',
overheadAllowed: true,
role: 'owner',
},
{
allowMemberPings: false,
colour: 'violet',
id: 'crewlink-group-coastline',
isOwner: false,
memberCount: 4,
name: 'Coastline Crew',
overheadAllowed: true,
role: 'member',
},
]
const crewLinkMembers = {
'crewlink-group-night-shift': [
{
coords: { x: -155.2, y: -1005.8, z: 28.4 },
id: 'crewlink-profile-skyline',
joinedAt: Date.now() - 36 * 86400000,
mapVisible: true,
online: true,
overheadVisible: false,
role: 'owner',
source: 1,
username: 'Skyline',
},
{
coords: { x: -67.6, y: -818.1, z: 326.2 },
id: 'crewlink-profile-nova',
joinedAt: Date.now() - 30 * 86400000,
mapVisible: true,
online: true,
overheadVisible: true,
role: 'coordinator',
source: 22,
username: 'Nova',
},
{
coords: { x: 214.8, y: -810.4, z: 30.7 },
id: 'crewlink-profile-ghost',
joinedAt: Date.now() - 20 * 86400000,
mapVisible: true,
online: true,
overheadVisible: true,
role: 'moderator',
source: 38,
username: 'Ghost',
},
{
id: 'crewlink-profile-luna',
joinedAt: Date.now() - 12 * 86400000,
mapVisible: false,
online: true,
overheadVisible: false,
role: 'member',
source: 41,
username: 'Luna',
},
{
id: 'crewlink-profile-mason',
joinedAt: Date.now() - 8 * 86400000,
mapVisible: true,
online: false,
overheadVisible: false,
role: 'member',
username: 'Mason',
},
{
id: 'crewlink-profile-raven',
joinedAt: Date.now() - 2 * 86400000,
mapVisible: true,
online: false,
overheadVisible: false,
role: 'guest',
username: 'Raven',
},
],
'crewlink-group-coastline': [
{
coords: { x: -1204.3, y: -1488.9, z: 4.4 },
id: 'crewlink-profile-skyline',
joinedAt: Date.now() - 5 * 86400000,
mapVisible: true,
online: true,
overheadVisible: false,
role: 'member',
source: 1,
username: 'Skyline',
},
{
coords: { x: -1315.5, y: -1520.2, z: 4.4 },
id: 'crewlink-profile-wave',
joinedAt: Date.now() - 40 * 86400000,
mapVisible: true,
online: true,
overheadVisible: true,
role: 'owner',
source: 18,
username: 'Wave',
},
{
id: 'crewlink-profile-sunset',
joinedAt: Date.now() - 20 * 86400000,
mapVisible: true,
online: false,
overheadVisible: false,
role: 'moderator',
username: 'Sunset',
},
{
id: 'crewlink-profile-finn',
joinedAt: Date.now() - 10 * 86400000,
mapVisible: false,
online: true,
overheadVisible: false,
role: 'guest',
source: 56,
username: 'Finn',
},
],
}
const crewLinkPings = {
'crewlink-group-night-shift': [
{
coords: { x: 233.1, y: -876.4, z: 30.5 },
createdAt: Date.now() - 45000,
creatorProfileId: 'crewlink-profile-ghost',
creatorUsername: 'Ghost',
expiresAt: Date.now() + 255000,
id: 'crewlink-ping-meeting',
label: 'Meet behind the garage',
type: 'meeting',
},
{
coords: { x: 452.6, y: -980.2, z: 30.7 },
createdAt: Date.now() - 110000,
creatorProfileId: 'crewlink-profile-nova',
creatorUsername: 'Nova',
expiresAt: Date.now() + 190000,
id: 'crewlink-ping-danger',
label: 'Avoid Mission Row',
type: 'danger',
},
{
coords: { x: -1034.2, y: -2732.1, z: 20.1 },
createdAt: Date.now() - 25000,
creatorProfileId: null,
creatorUsername: 'sky_mission',
expiresAt: Date.now() + 275000,
id: 'crewlink-ping-target',
label: 'Airport pickup',
sourceResource: 'sky_mission',
type: 'target',
},
],
'crewlink-group-coastline': [
{
coords: { x: -1302.2, y: -1541.4, z: 4.2 },
createdAt: Date.now() - 80000,
creatorProfileId: 'crewlink-profile-wave',
creatorUsername: 'Wave',
expiresAt: Date.now() + 220000,
id: 'crewlink-ping-coast',
label: 'Boardwalk meeting',
type: 'info',
},
],
}
let crewLinkInvitations = [
{
colour: 'orange',
expiresAt: Date.now() + 25000,
groupId: 'crewlink-group-sandy',
groupName: 'Sandy Trails',
id: 'crewlink-invite-sandy',
inviterUsername: 'Dusty',
},
]
const crewLinkNearby = [
{ distance: 2.4, source: 72, username: 'Echo' },
{ distance: 4.7, source: 83, username: 'Atlas' },
]
const crewLinkLimits = {
maximumGroups: 5,
maximumMembers: 16,
nearbyDistance: 5,
overheadDistance: 50,
pingLifetimeSeconds: 300,
}
function crewLinkBootstrap(testScenario = '') {
if (testScenario === 'crewlink-onboarding') {
return { groups: [], invitations: [], profile: null }
}
if (testScenario === 'crewlink-empty') {
return {
activeGroup: null,
groups: [],
invitations: crewLinkInvitations,
limits: crewLinkLimits,
profile: { ...crewLinkProfile, activeGroupId: null },
}
}
const activeSummary = crewLinkGroups.find(
(group) => group.id === crewLinkProfile.activeGroupId,
)
return {
activeGroup: activeSummary
? {
...activeSummary,
members: crewLinkMembers[activeSummary.id] ?? [],
pings: crewLinkPings[activeSummary.id] ?? [],
}
: null,
groups: crewLinkGroups,
invitations: crewLinkInvitations,
limits: crewLinkLimits,
profile: crewLinkProfile,
}
}
const flipTokProfile = {
id: 1,
handle: 'skyline',
@@ -2422,6 +2659,260 @@ app.post('/api/:endpoint', (request, response) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint
const testScenario = String(request.body._testScenario ?? '')
if (endpoint === 'crewlink:bootstrap') {
response.json({ success: true, data: crewLinkBootstrap(testScenario) })
return
}
if (endpoint === 'crewlink:create-profile') {
const username = String(request.body.username ?? '').trim()
if (!/^[A-Za-z0-9][A-Za-z0-9._]{1,18}[A-Za-z0-9]$/.test(username)) {
response.json({ success: false, error: 'invalid_username' })
return
}
crewLinkProfile = {
activeGroupId: null,
id: `crewlink-profile-${Date.now()}`,
mapVisible: true,
overheadVisible: false,
username,
}
response.json({ success: true, data: crewLinkBootstrap() })
return
}
if (endpoint === 'crewlink:update-profile') {
crewLinkProfile = {
...crewLinkProfile,
mapVisible: request.body.mapVisible === true,
overheadVisible: request.body.overheadVisible === true,
username: String(request.body.username ?? crewLinkProfile.username),
}
for (const members of Object.values(crewLinkMembers)) {
const own = members.find((member) => member.id === crewLinkProfile.id)
if (own) {
own.mapVisible = crewLinkProfile.mapVisible
own.overheadVisible = crewLinkProfile.overheadVisible
own.username = crewLinkProfile.username
}
}
response.json({ success: true, data: crewLinkBootstrap() })
return
}
if (endpoint === 'crewlink:create-group') {
const name = String(request.body.name ?? '').trim()
if (name.length < 3) {
response.json({ success: false, error: 'invalid_group' })
return
}
const id = `crewlink-group-${Date.now()}`
crewLinkGroups.unshift({
allowMemberPings: true,
colour: request.body.colour ?? 'cyan',
id,
inviteCode: Math.random().toString(36).slice(2, 10).toUpperCase(),
isOwner: true,
memberCount: 1,
name,
overheadAllowed: true,
role: 'owner',
})
crewLinkMembers[id] = [
{
coords: { x: -155.2, y: -1005.8, z: 28.4 },
id: crewLinkProfile.id,
joinedAt: Date.now(),
mapVisible: crewLinkProfile.mapVisible,
online: true,
overheadVisible: crewLinkProfile.overheadVisible,
role: 'owner',
source: 1,
username: crewLinkProfile.username,
},
]
crewLinkPings[id] = []
crewLinkProfile.activeGroupId = id
response.json({ success: true, data: crewLinkBootstrap() })
return
}
if (endpoint === 'crewlink:update-group') {
const group = crewLinkGroups.find((item) => item.id === request.body.groupId)
if (!group) {
response.json({ success: false, error: 'group_not_found' })
return
}
Object.assign(group, {
allowMemberPings: request.body.allowMemberPings,
colour: request.body.colour,
name: request.body.name,
overheadAllowed: request.body.overheadAllowed,
})
response.json({ success: true })
return
}
if (endpoint === 'crewlink:delete-group') {
crewLinkGroups = crewLinkGroups.filter((item) => item.id !== request.body.groupId)
delete crewLinkMembers[request.body.groupId]
delete crewLinkPings[request.body.groupId]
crewLinkProfile.activeGroupId = crewLinkGroups[0]?.id ?? null
response.json({ success: true })
return
}
if (endpoint === 'crewlink:set-active') {
const group = crewLinkGroups.find((item) => item.id === request.body.groupId)
if (!group) {
response.json({ success: false, error: 'group_not_found' })
return
}
crewLinkProfile.activeGroupId = group.id
response.json({ success: true, data: crewLinkBootstrap() })
return
}
if (endpoint === 'crewlink:join-code') {
if (String(request.body.code).toUpperCase() !== 'SANDY247') {
response.json({ success: false, error: 'invalid_code' })
return
}
const id = 'crewlink-group-sandy'
if (!crewLinkGroups.some((group) => group.id === id)) {
crewLinkGroups.push({
allowMemberPings: true,
colour: 'orange',
id,
isOwner: false,
memberCount: 3,
name: 'Sandy Trails',
overheadAllowed: false,
role: 'member',
})
crewLinkMembers[id] = [
{
coords: { x: 1702.1, y: 3591.4, z: 35.4 },
id: crewLinkProfile.id,
joinedAt: Date.now(),
mapVisible: true,
online: true,
overheadVisible: false,
role: 'member',
username: crewLinkProfile.username,
},
]
crewLinkPings[id] = []
}
crewLinkProfile.activeGroupId = id
response.json({ success: true, data: crewLinkBootstrap() })
return
}
if (endpoint === 'crewlink:rotate-code') {
const group = crewLinkGroups.find((item) => item.id === request.body.groupId)
if (group) group.inviteCode = 'FRESH247'
response.json({ success: true, data: { inviteCode: 'FRESH247' } })
return
}
if (endpoint === 'crewlink:nearby') {
response.json({ success: true, data: crewLinkNearby })
return
}
if (endpoint === 'crewlink:invite-nearby') {
response.json({ success: true })
return
}
if (endpoint === 'crewlink:respond-invite') {
const invitation = crewLinkInvitations.find(
(item) => item.id === request.body.invitationId,
)
crewLinkInvitations = crewLinkInvitations.filter(
(item) => item.id !== request.body.invitationId,
)
if (request.body.accepted && invitation) {
crewLinkGroups.push({
allowMemberPings: true,
colour: invitation.colour,
id: invitation.groupId,
isOwner: false,
memberCount: 4,
name: invitation.groupName,
overheadAllowed: true,
role: 'member',
})
crewLinkMembers[invitation.groupId] = []
crewLinkPings[invitation.groupId] = []
crewLinkProfile.activeGroupId = invitation.groupId
}
response.json({ success: true, data: crewLinkBootstrap() })
return
}
if (endpoint === 'crewlink:update-member') {
const members = crewLinkMembers[request.body.groupId] ?? []
const member = members.find((item) => item.id === request.body.profileId)
if (member) member.role = request.body.role
response.json({ success: true })
return
}
if (endpoint === 'crewlink:transfer-owner') {
const group = crewLinkGroups.find((item) => item.id === request.body.groupId)
const members = crewLinkMembers[request.body.groupId] ?? []
const current = members.find((item) => item.id === crewLinkProfile.id)
const next = members.find((item) => item.id === request.body.profileId)
if (group) {
group.isOwner = false
group.role = 'coordinator'
delete group.inviteCode
}
if (current) current.role = 'coordinator'
if (next) next.role = 'owner'
response.json({ success: true })
return
}
if (endpoint === 'crewlink:remove-member') {
const members = crewLinkMembers[request.body.groupId] ?? []
crewLinkMembers[request.body.groupId] = members.filter(
(item) => item.id !== request.body.profileId,
)
const group = crewLinkGroups.find((item) => item.id === request.body.groupId)
if (group) group.memberCount = crewLinkMembers[request.body.groupId].length
response.json({ success: true })
return
}
if (endpoint === 'crewlink:leave') {
crewLinkGroups = crewLinkGroups.filter((item) => item.id !== request.body.groupId)
crewLinkProfile.activeGroupId = crewLinkGroups[0]?.id ?? null
response.json({ success: true, data: crewLinkBootstrap() })
return
}
if (endpoint === 'crewlink:create-ping') {
const groupId = crewLinkProfile.activeGroupId
const ping = {
coords: request.body.coords ?? { x: -155.2, y: -1005.8, z: 28.4 },
createdAt: Date.now(),
creatorProfileId: crewLinkProfile.id,
creatorUsername: crewLinkProfile.username,
expiresAt: Date.now() + 300000,
id: `crewlink-ping-${Date.now()}`,
label: request.body.label,
type: request.body.type,
}
crewLinkPings[groupId] = [ping, ...(crewLinkPings[groupId] ?? [])]
response.json({ success: true, data: ping })
return
}
if (endpoint === 'crewlink:remove-ping') {
const groupId = crewLinkProfile.activeGroupId
crewLinkPings[groupId] = (crewLinkPings[groupId] ?? []).filter(
(item) => item.id !== request.body.pingId,
)
response.json({ success: true })
return
}
if (endpoint === 'crewlink:live') {
const groupId = crewLinkProfile.activeGroupId
response.json({
success: true,
data: {
members: crewLinkMembers[groupId] ?? [],
pings: crewLinkPings[groupId] ?? [],
},
})
return
}
if (endpoint === 'feather:bootstrap') {
const empty = testScenario === 'feather-empty'
const visibleProfiles = featherProfiles.filter(
+23
View File
@@ -393,6 +393,29 @@ Config.MapMarkers = {
ActionsPerMinute = 60,
}
Config.CrewLink = {
UsernameMinLength = 3,
UsernameMaxLength = 20,
GroupNameMinLength = 3,
GroupNameMaxLength = 32,
MaximumGroupsPerProfile = 5,
MaximumMembersPerGroup = 16,
InviteCodeLength = 8,
InviteLifetimeSeconds = 30,
NearbyInviteDistance = 5.0,
NearbyScanLimit = 12,
PingLabelMaxLength = 48,
PingLifetimeSeconds = 300,
MaximumActivePings = 12,
ActionsPerMinute = 30,
LiveRequestsPerMinute = 120,
OverheadRefreshMilliseconds = 3000,
OverheadDistance = 50.0,
ExternalPingResources = {
-- ["example_resource"] = true,
},
}
Config.Calendar = {
TitleMaxLength = 120,
NoteMaxLength = 2000,
+37
View File
@@ -114,6 +114,43 @@ Locales["en"] = {
},
},
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 3-20 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", signInBody = "Flare profiles and matches are linked to your private iFruit account.",
createProfile = "Create your profile", welcome = "Find your spark", welcomeBody = "Build a profile and meet people across Los Santos.",
+2
View File
@@ -31,6 +31,7 @@ client_scripts {
'source/client/garage.lua',
'source/client/skyride.lua',
'source/client/housing.lua',
'source/client/crewlink.lua',
'source/bridge/client/radio.lua',
'source/client/payphones.lua',
'source/client/main.lua',
@@ -72,6 +73,7 @@ server_scripts {
'source/server/picstagram.lua',
'source/server/feather.lua',
'source/server/map.lua',
'source/server/crewlink.lua',
'source/server/skyride.lua',
'source/server/calendar.lua',
'source/server/music.lua',
+44
View File
@@ -0,0 +1,44 @@
local overhead_members = {}
local function draw_overhead_label(coords, username, role)
SetDrawOrigin(coords.x, coords.y, coords.z + 1.05, 0)
SetTextScale(0.0, 0.29)
SetTextFont(4)
SetTextProportional(true)
SetTextColour(235, 245, 255, 235)
SetTextCentre(true)
SetTextOutline()
BeginTextCommandDisplayText("STRING")
AddTextComponentSubstringPlayerName(("~b~%s~s~ %s"):format(username, role))
EndTextCommandDisplayText(0.0, 0.0)
ClearDrawOrigin()
end
CreateThread(function()
while true do
local result = Bridge.Callbacks.Trigger("sky_phone:crewlink:overhead", {})
overhead_members = result and result.success and result.data and result.data.members or {}
Wait(Config.CrewLink.OverheadRefreshMilliseconds)
end
end)
CreateThread(function()
while true do
local sleep = 1000
if #overhead_members > 0 then
sleep = 0
local player_coords = GetEntityCoords(PlayerPedId())
for _, member in ipairs(overhead_members) do
local player = GetPlayerFromServerId(member.source)
if player ~= -1 then
local ped = GetPlayerPed(player)
local coords = GetEntityCoords(ped)
if #(player_coords - coords) <= Config.CrewLink.OverheadDistance then
draw_overhead_label(coords, member.username, member.roleLabel)
end
end
end
end
Wait(sleep)
end
end)
+35
View File
@@ -147,6 +147,25 @@ local server_callbacks = {
"map:markers",
"map:create-marker",
"map:delete-marker",
"crewlink:bootstrap",
"crewlink:create-profile",
"crewlink:update-profile",
"crewlink:create-group",
"crewlink:update-group",
"crewlink:delete-group",
"crewlink:set-active",
"crewlink:join-code",
"crewlink:rotate-code",
"crewlink:nearby",
"crewlink:invite-nearby",
"crewlink:respond-invite",
"crewlink:update-member",
"crewlink:transfer-owner",
"crewlink:remove-member",
"crewlink:leave",
"crewlink:create-ping",
"crewlink:remove-ping",
"crewlink:live",
"sim:insert",
"sim:eject",
"contacts:list",
@@ -617,6 +636,22 @@ RegisterNetEvent("sky_phone:billing:new", function(data)
SendNUIMessage({ type = "billing:new", data = data })
end)
RegisterNetEvent("sky_phone:crewlink:changed", function(data)
SendNUIMessage({ type = "crewlink:changed", data = data })
end)
RegisterNetEvent("sky_phone:crewlink:notification", function(data)
local crewlink_locale = get_locale().Nui.Apps.crewlink
local notification_text = crewlink_locale.notifications[data.kind]
or crewlink_locale.notifications.default
data.title = crewlink_locale.name
data.text = notification_text
:gsub("{actor}", tostring(data.actor or ""))
:gsub("{group}", tostring(data.groupName or ""))
:gsub("{ping}", tostring(data.pingLabel or ""))
SendNUIMessage({ type = "crewlink:notification", data = data })
end)
RegisterNetEvent("sky_phone:messages:changed", function(data)
SendNUIMessage({ type = "messages:changed", data = data })
end)
File diff suppressed because it is too large Load Diff
+122
View File
@@ -1947,6 +1947,128 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_crewlink_profiles",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "username", type = "VARCHAR(20) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
{ name = "active_group_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "map_visible", type = "TINYINT(1) NOT NULL DEFAULT 1" },
{ name = "overhead_visible", type = "TINYINT(1) NOT NULL DEFAULT 0" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_crewlink_account", columns = "(`account_id`)" },
{ name = "uniq_sky_phone_crewlink_username", columns = "(`username`)" },
},
indexes = {
{ name = "idx_sky_phone_crewlink_active", columns = "(`active_group_id`)" },
},
foreignKeys = {
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_crewlink_groups",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "name", type = "VARCHAR(32) NOT NULL" },
{ name = "colour", type = "ENUM('cyan','blue','violet','orange','green','rose') NOT NULL DEFAULT 'cyan'" },
{ name = "owner_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "invite_code", type = "VARCHAR(12) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "allow_member_pings", type = "TINYINT(1) NOT NULL DEFAULT 1" },
{ name = "overhead_allowed", type = "TINYINT(1) NOT NULL DEFAULT 1" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_crewlink_invite_code", columns = "(`invite_code`)" },
},
indexes = {
{ name = "idx_sky_phone_crewlink_owner", columns = "(`owner_profile_id`)" },
},
foreignKeys = {
{ column = "owner_profile_id", references = "`sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_crewlink_memberships",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "group_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "role", type = "ENUM('owner','coordinator','moderator','member','guest') NOT NULL DEFAULT 'member'" },
{ name = "joined_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_crewlink_membership", columns = "(`group_id`, `profile_id`)" },
},
indexes = {
{ name = "idx_sky_phone_crewlink_profile_groups", columns = "(`profile_id`, `joined_at`)" },
},
foreignKeys = {
{ column = "group_id", references = "`sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE" },
{ column = "profile_id", references = "`sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_crewlink_invitations",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "group_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "inviter_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "invitee_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "status", type = "ENUM('pending','accepted','declined','expired') NOT NULL DEFAULT 'pending'" },
{ name = "expires_at", type = "DATETIME NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_crewlink_pending_invite", columns = "(`group_id`, `invitee_profile_id`)" },
},
indexes = {
{ name = "idx_sky_phone_crewlink_invitee", columns = "(`invitee_profile_id`, `status`, `expires_at`)" },
},
foreignKeys = {
{ column = "group_id", references = "`sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE" },
{ column = "inviter_profile_id", references = "`sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE" },
{ column = "invitee_profile_id", references = "`sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_crewlink_pings",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "group_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "creator_profile_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "source_resource", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_general_ci" },
{ name = "type", type = "ENUM('meeting','danger','help','target','info') NOT NULL" },
{ name = "label", type = "VARCHAR(48) NOT NULL" },
{ name = "position_x", type = "DECIMAL(10,3) NOT NULL" },
{ name = "position_y", type = "DECIMAL(10,3) NOT NULL" },
{ name = "position_z", type = "DECIMAL(10,3) NOT NULL" },
{ name = "expires_at", type = "DATETIME NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_crewlink_pings", columns = "(`group_id`, `expires_at`)" },
},
foreignKeys = {
{ column = "group_id", references = "`sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE" },
{ column = "creator_profile_id", references = "`sky_phone_crewlink_profiles` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
}
Bridge.Database.Migrate("sky_phone", schema)
+79
View File
@@ -850,3 +850,82 @@ CREATE TABLE IF NOT EXISTS `sky_phone_feather_reports` (
FOREIGN KEY (`reporter_id`) REFERENCES `sky_phone_feather_profiles` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`post_id`) REFERENCES `sky_phone_feather_posts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_profiles` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`account_id` BIGINT UNSIGNED NOT NULL,
`username` VARCHAR(20) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`active_group_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`map_visible` TINYINT(1) NOT NULL DEFAULT 1,
`overhead_visible` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_crewlink_account` (`account_id`),
UNIQUE KEY `uniq_sky_phone_crewlink_username` (`username`),
KEY `idx_sky_phone_crewlink_active` (`active_group_id`),
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_groups` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`name` VARCHAR(32) NOT NULL,
`colour` ENUM('cyan','blue','violet','orange','green','rose') NOT NULL DEFAULT 'cyan',
`owner_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`invite_code` VARCHAR(12) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`allow_member_pings` TINYINT(1) NOT NULL DEFAULT 1,
`overhead_allowed` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_crewlink_invite_code` (`invite_code`),
KEY `idx_sky_phone_crewlink_owner` (`owner_profile_id`),
FOREIGN KEY (`owner_profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_memberships` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`group_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`role` ENUM('owner','coordinator','moderator','member','guest') NOT NULL DEFAULT 'member',
`joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_crewlink_membership` (`group_id`,`profile_id`),
KEY `idx_sky_phone_crewlink_profile_groups` (`profile_id`,`joined_at`),
FOREIGN KEY (`group_id`) REFERENCES `sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_invitations` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`group_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`inviter_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`invitee_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`status` ENUM('pending','accepted','declined','expired') NOT NULL DEFAULT 'pending',
`expires_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_crewlink_pending_invite` (`group_id`,`invitee_profile_id`),
KEY `idx_sky_phone_crewlink_invitee` (`invitee_profile_id`,`status`,`expires_at`),
FOREIGN KEY (`group_id`) REFERENCES `sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`inviter_profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`invitee_profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_pings` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`group_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`creator_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`source_resource` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NULL,
`type` ENUM('meeting','danger','help','target','info') NOT NULL,
`label` VARCHAR(48) NOT NULL,
`position_x` DECIMAL(10,3) NOT NULL,
`position_y` DECIMAL(10,3) NOT NULL,
`position_z` DECIMAL(10,3) NOT NULL,
`expires_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_crewlink_pings` (`group_id`,`expires_at`),
FOREIGN KEY (`group_id`) REFERENCES `sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`creator_profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;