mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
ADD - merge CrewLink app
This commit is contained in:
@@ -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 |
@@ -150,6 +150,7 @@ describe('app registry', () => {
|
||||
'flare',
|
||||
'radio',
|
||||
'local-pages',
|
||||
'crewlink',
|
||||
'phone',
|
||||
'darkchat',
|
||||
'banking',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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 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',
|
||||
|
||||
@@ -35,6 +35,7 @@ export type PhoneAppId =
|
||||
| 'picstagram'
|
||||
| 'skyride'
|
||||
| 'feather'
|
||||
| 'crewlink'
|
||||
|
||||
export type LaunchablePhoneAppId = PhoneAppId
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user