ENH - unify social app accounts and profiles

This commit is contained in:
smx.pusha
2026-08-12 21:21:01 +02:00
parent 1c58eb772a
commit 011b067846
23 changed files with 6117 additions and 797 deletions
+6
View File
@@ -32,6 +32,7 @@ import { useBankingStore } from '@/stores/banking'
import { useBillingStore } from '@/stores/billing'
import { useCompaniesStore } from '@/stores/companies'
import { useAccountStore } from '@/stores/account'
import { useAppAuthStore } from '@/stores/app-auth'
import { useMailStore } from '@/stores/mail'
import { useMessagesStore } from '@/stores/messages'
import { useDarkChatStore } from '@/stores/darkchat'
@@ -241,6 +242,7 @@ const isDevelopment = import.meta.env.DEV
const phone = usePhoneStore()
const account = useAccountStore()
const appAuth = useAppAuthStore()
const clock = useClockStore()
const games = useGamesStore()
const calls = useCallsStore()
@@ -342,6 +344,10 @@ function hydratePhone(payload: PhoneOpenPayload): void {
notifications.hideDevicePreview(payload.device.imei)
}
account.hydrate(payload.account ?? null)
appAuth.hydrate(
payload.device?.data.appAuth?.payload,
payload.account?.email ?? '',
)
notes.hydrate(payload.notes ?? [])
clock.hydrate(payload.device?.data.alarms?.payload)
games.hydrate(payload.device?.data.games?.payload)
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { kDialog, kDialogButton } from 'konsta/vue'
import { useAppAuthStore, type AppAuthId } from '@/stores/app-auth'
import { useCrewLinkStore } from '@/stores/crewlink'
import { useFeatherStore } from '@/stores/feather'
import { useMarketplaceStore } from '@/stores/marketplace'
import { usePagesStore } from '@/stores/pages'
import { usePhoneStore } from '@/stores/phone'
const opened = defineModel<boolean>('opened', { default: false })
const emit = defineEmits<{ loggedOut: [] }>()
const props = defineProps<{ appId: AppAuthId; appName: string }>()
const appAuth = useAppAuthStore()
const crewLink = useCrewLinkStore()
const feather = useFeatherStore()
const marketplace = useMarketplaceStore()
const pages = usePagesStore()
const phone = usePhoneStore()
function close(): void {
opened.value = false
}
function confirmLogout(): void {
appAuth.signOut(props.appId)
if (props.appId === 'citymarkt') marketplace.$reset()
if (props.appId === 'local-pages') pages.$reset()
if (props.appId === 'feather') feather.$reset()
if (props.appId === 'crewlink') {
crewLink.$reset()
crewLink.error = 'not_authenticated'
}
opened.value = false
emit('loggedOut')
}
</script>
<template>
<k-dialog :opened="opened" @backdropclick="close">
<template #title>{{ phone.t('Common.signOutTitle', { app: appName }) }}</template>
<p>{{ phone.t('Common.signOutBody', { app: appName }) }}</p>
<template #buttons>
<k-dialog-button @click="close">
{{ phone.t('Common.cancel') }}
</k-dialog-button>
<k-dialog-button
strong
class="account-logout-confirm"
@click="confirmLogout"
>
{{ phone.t('Common.signOut') }}
</k-dialog-button>
</template>
</k-dialog>
</template>
<style scoped>
.account-logout-confirm {
background: #e44760 !important;
color: #fff !important;
}
</style>
@@ -0,0 +1,391 @@
<script setup lang="ts">
import {
ArrowRight,
Camera,
Images,
LockKeyhole,
Mail,
UserRound,
} from 'lucide-vue-next'
import {
kButton,
kGlass,
kList,
kListInput,
kPreloader,
kSegmented,
kSegmentedButton,
} from 'konsta/vue'
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
avatarUrl: string | null
body: string
cameraLabel: string
email: string
emailLabel: string
error: string
eyebrow: string
galleryLabel: string
loginLabel: string
maxUsernameLength?: number
minUsernameLength?: number
mode: 'login' | 'register'
pending: boolean
registerLabel: string
title: string
username: string
usernameLabel: string
usernamePlaceholder?: string
}>(),
{
maxUsernameLength: 40,
minUsernameLength: 2,
usernamePlaceholder: '',
},
)
const emit = defineEmits<{
camera: []
gallery: []
submit: []
'update:mode': [value: 'login' | 'register']
'update:username': [value: string]
}>()
const canSubmit = computed(() => {
const length = props.username.trim().length
return Boolean(
props.email &&
length >= props.minUsernameLength &&
length <= props.maxUsernameLength,
)
})
</script>
<template>
<section class="app-profile-auth">
<header class="app-profile-auth__hero">
<span class="app-profile-auth__mark"><UserRound :size="23" /></span>
<div>
<small>{{ eyebrow }}</small>
<h2>{{ title }}</h2>
</div>
<p>{{ body }}</p>
</header>
<k-glass class="app-profile-auth__card">
<k-segmented raised class="app-profile-auth__mode">
<k-segmented-button
:active="mode === 'login'"
@click="emit('update:mode', 'login')"
>
{{ loginLabel }}
</k-segmented-button>
<k-segmented-button
:active="mode === 'register'"
@click="emit('update:mode', 'register')"
>
{{ registerLabel }}
</k-segmented-button>
</k-segmented>
<div v-if="mode === 'register'" class="app-profile-auth__photo">
<span class="app-profile-auth__avatar">
<img v-if="avatarUrl" :src="avatarUrl" alt="" />
<UserRound v-else :size="28" />
<i><Camera :size="11" /></i>
</span>
<div>
<k-button rounded outline @click="emit('gallery')">
<Images :size="15" />{{ galleryLabel }}
</k-button>
<k-button rounded outline @click="emit('camera')">
<Camera :size="15" />{{ cameraLabel }}
</k-button>
</div>
</div>
<div class="app-profile-auth__identity">
<span><Mail :size="17" /></span>
<div>
<small>{{ emailLabel }}</small>
<strong>{{ email }}</strong>
</div>
<LockKeyhole :size="15" />
</div>
<k-list inset strong class="app-profile-auth__fields">
<k-list-input
input-id="app-profile-auth-username"
:label="usernameLabel"
:value="username"
:maxlength="maxUsernameLength"
:placeholder="usernamePlaceholder"
autocomplete="username"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
outline
@input="
emit('update:username', ($event.target as HTMLInputElement).value)
"
@keydown.enter="emit('submit')"
/>
</k-list>
<div v-if="error" class="app-profile-auth__error" role="alert">
{{ error }}
</div>
<k-button
large
rounded
class="app-profile-auth__submit"
:disabled="!canSubmit || pending"
@click="emit('submit')"
>
<k-preloader v-if="pending" />
<template v-else>
<span>{{ mode === 'login' ? loginLabel : registerLabel }}</span>
<ArrowRight :size="18" />
</template>
</k-button>
</k-glass>
</section>
</template>
<style scoped>
.app-profile-auth {
width: 100%;
max-width: 320px;
margin: auto;
padding: 12px 4px 18px;
color: inherit;
text-align: center;
}
.app-profile-auth__hero {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 46px minmax(0, 1fr);
gap: 0 11px;
margin: 0 12px 14px;
text-align: left;
}
.app-profile-auth__mark {
display: grid;
width: 46px;
height: 46px;
grid-row: 1 / span 2;
place-items: center;
border: 1px solid
color-mix(in srgb, var(--auth-accent, #ffd63e) 46%, transparent);
border-radius: 15px;
color: var(--auth-accent, var(--yellow, #ffd63e));
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 14%, transparent);
box-shadow: 0 10px 28px
color-mix(in srgb, var(--auth-accent, #ffd63e) 16%, transparent);
}
.app-profile-auth__hero small {
color: var(--auth-accent, var(--yellow, #ffd63e));
font-size: 9px;
font-weight: 850;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.app-profile-auth__hero h2 {
margin: 3px 0 0;
color: inherit;
font-size: 20px;
line-height: 1.08;
}
.app-profile-auth__hero p {
grid-column: 2;
margin: 6px 0 0;
color: var(--muted, #9ba4aa);
font-size: 11px;
line-height: 1.35;
}
.app-profile-auth__card {
position: relative;
display: block;
padding: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
background: color-mix(in srgb, var(--panel, #20262c) 90%, transparent);
box-shadow: 0 22px 50px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(22px) saturate(1.15);
}
.app-profile-auth__card::before {
position: absolute;
top: -80px;
right: -55px;
width: 170px;
height: 150px;
border-radius: 50%;
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 16%, transparent);
filter: blur(38px);
content: '';
pointer-events: none;
}
.app-profile-auth__mode {
position: relative;
z-index: 1;
margin: 0 0 12px;
padding: 3px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(0, 0, 0, 0.16);
}
.app-profile-auth__photo {
display: flex;
align-items: center;
gap: 12px;
margin: 0 2px 11px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.035);
text-align: left;
}
.app-profile-auth__avatar {
position: relative;
display: grid;
width: 66px;
height: 66px;
flex: none;
place-items: center;
border: 2px solid
color-mix(in srgb, var(--auth-accent, #ffd63e) 58%, transparent);
border-radius: 50%;
color: var(--auth-accent, var(--yellow, #ffd63e));
background: color-mix(
in srgb,
var(--auth-accent, #ffd63e) 10%,
var(--panel, #20262c)
);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
}
.app-profile-auth__photo img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.app-profile-auth__avatar i {
position: absolute;
right: -2px;
bottom: -1px;
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 2px solid var(--panel, #20262c);
border-radius: 50%;
color: #fff;
background: var(--auth-accent, #ffd63e);
}
.app-profile-auth__photo > div {
display: grid;
min-width: 0;
flex: 1;
gap: 6px;
}
.app-profile-auth__photo :deep(.k-button) {
min-height: 32px;
justify-content: flex-start;
gap: 6px;
border-color: rgba(255, 255, 255, 0.12);
color: inherit;
background: rgba(255, 255, 255, 0.04);
font-size: 11px;
}
.app-profile-auth__identity {
position: relative;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 18px;
align-items: center;
gap: 9px;
margin-bottom: 9px;
padding: 9px 11px;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 15px;
background: rgba(255, 255, 255, 0.045);
text-align: left;
}
.app-profile-auth__identity > span {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 11px;
color: var(--auth-accent, #ffd63e);
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 13%, transparent);
}
.app-profile-auth__identity div {
min-width: 0;
}
.app-profile-auth__identity small {
display: block;
margin-bottom: 1px;
color: var(--muted, #9ba4aa);
font-size: 9px;
}
.app-profile-auth__identity strong {
display: block;
overflow: hidden;
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-profile-auth__identity > svg {
color: var(--muted, #9ba4aa);
}
.app-profile-auth__fields {
margin-top: 0;
margin-right: 0;
margin-bottom: 11px;
margin-left: 0;
color: inherit;
background: rgba(255, 255, 255, 0.045) !important;
text-align: left;
}
.app-profile-auth__fields :deep(.text-black) {
color: inherit !important;
}
.app-profile-auth__fields :deep(.text-xs > div) {
background: color-mix(
in srgb,
var(--panel, #20262c) 94%,
transparent
) !important;
}
.app-profile-auth__error {
margin: -2px 1px 10px;
padding: 8px 10px;
border: 1px solid rgba(255, 105, 97, 0.22);
border-radius: 11px;
color: #ff6961;
background: rgba(255, 105, 97, 0.08);
font-size: 11px;
}
.app-profile-auth__submit {
--k-button-bg-color: var(--auth-accent, var(--yellow, #ffd63e));
--k-button-text-color: #fff;
width: 100%;
min-height: 44px;
display: flex;
justify-content: space-between;
padding: 0 17px;
color: #fff !important;
background: var(--auth-accent, var(--yellow, #ffd63e)) !important;
box-shadow: 0 10px 26px
color-mix(in srgb, var(--auth-accent, #ffd63e) 25%, transparent);
font-weight: 750;
}
.app-profile-auth__submit:disabled {
opacity: 0.46;
}
</style>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import {
kButton,
kList,
kListInput,
kPreloader,
kSegmented,
kSegmentedButton,
} from 'konsta/vue'
import { computed, ref } from 'vue'
import { useAccountStore } from '@/stores/account'
import { useAppAuthStore, type AppAuthId } from '@/stores/app-auth'
import { usePhoneStore } from '@/stores/phone'
import {
filterMailAddressInput,
MAIL_ADDRESS_INPUT_MAX_LENGTH,
normalizeMailAddress,
} from '@/utils/mail'
const props = defineProps<{
appId: AppAuthId
appName: string
}>()
const emit = defineEmits<{ signedIn: [] }>()
const account = useAccountStore()
const appAuth = useAppAuthStore()
const phone = usePhoneStore()
const mode = ref<'login' | 'register'>('login')
const email = ref(account.email)
const password = ref('')
const confirm = ref('')
const pending = ref(false)
const error = ref('')
const canSubmit = computed(() => {
const normalized = normalizeMailAddress(email.value)
const passwordValid = password.value.length >= 6 && password.value.length <= 64
return Boolean(
normalized &&
passwordValid &&
(mode.value === 'login' || (confirm.value && confirm.value === password.value)),
)
})
function setMode(next: 'login' | 'register'): void {
mode.value = next
confirm.value = ''
error.value = ''
}
function updateEmail(event: Event): void {
email.value = filterMailAddressInput((event.target as HTMLInputElement).value)
}
function errorMessage(key?: string): string {
const known = [
'invalid_email',
'invalid_password',
'invalid_credentials',
'email_taken',
'rate_limited',
]
return phone.t(
`Common.appAuth.errors.${key && known.includes(key) ? key : 'default'}`,
)
}
async function submit(): Promise<void> {
if (!canSubmit.value || pending.value) return
const normalized = normalizeMailAddress(email.value)
if (!normalized) return
pending.value = true
error.value = ''
const response =
mode.value === 'login'
? await account.login(normalized, password.value)
: await account.register(normalized, password.value)
pending.value = false
if (!response.success || !response.data) {
error.value = errorMessage(response.error)
return
}
appAuth.signIn(props.appId, response.data.email)
password.value = ''
confirm.value = ''
emit('signedIn')
}
</script>
<template>
<section class="ifruit-app-auth">
<small>{{ phone.t('Common.appAuth.eyebrow') }}</small>
<h2>{{ phone.t('Common.appAuth.title', { app: appName }) }}</h2>
<p>{{ phone.t('Common.appAuth.body', { app: appName }) }}</p>
<k-segmented raised class="ifruit-app-auth__mode">
<k-segmented-button :active="mode === 'login'" @click="setMode('login')">
{{ phone.t('Common.appAuth.login') }}
</k-segmented-button>
<k-segmented-button :active="mode === 'register'" @click="setMode('register')">
{{ phone.t('Common.appAuth.register') }}
</k-segmented-button>
</k-segmented>
<k-list inset strong class="ifruit-app-auth__fields">
<k-list-input
input-id="ifruit-app-auth-email"
:label="phone.t('Common.appAuth.email')"
:value="email"
:maxlength="MAIL_ADDRESS_INPUT_MAX_LENGTH"
autocomplete="username"
inputmode="email"
outline
@input="updateEmail"
/>
<k-list-input
input-id="ifruit-app-auth-password"
:label="phone.t('Common.appAuth.password')"
:value="password"
type="password"
maxlength="64"
:autocomplete="mode === 'login' ? 'current-password' : 'new-password'"
outline
@input="password = ($event.target as HTMLInputElement).value"
/>
<k-list-input
v-if="mode === 'register'"
input-id="ifruit-app-auth-confirm"
:label="phone.t('Common.appAuth.confirm')"
:value="confirm"
type="password"
maxlength="64"
autocomplete="new-password"
outline
@input="confirm = ($event.target as HTMLInputElement).value"
/>
</k-list>
<p v-if="error" class="ifruit-app-auth__error" role="alert">{{ error }}</p>
<k-button large rounded :disabled="!canSubmit || pending" @click="submit">
<k-preloader v-if="pending" />
<template v-else>
{{ phone.t(mode === 'login' ? 'Common.appAuth.loginAction' : 'Common.appAuth.registerAction') }}
</template>
</k-button>
</section>
</template>
<style scoped>
.ifruit-app-auth {
width: 100%;
max-width: 310px;
margin: auto;
padding: 18px 10px;
text-align: center;
}
.ifruit-app-auth > small {
color: var(--k-color-primary);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ifruit-app-auth h2 {
margin: 6px 0 4px;
font-size: 21px;
}
.ifruit-app-auth > p {
margin: 0 auto 14px;
color: #8e8e93;
font-size: 12px;
line-height: 1.4;
}
.ifruit-app-auth__mode {
margin: 0 8px 12px;
}
.ifruit-app-auth__fields {
margin-top: 0;
margin-bottom: 12px;
text-align: left;
}
.ifruit-app-auth .ifruit-app-auth__error {
margin: -4px 12px 10px;
color: #ff453a;
font-size: 11px;
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import AppProfileAuth from '@/components/account/AppProfileAuth.vue'
import { usePhoneStore } from '@/stores/phone'
defineProps<{
avatarUrl: string | null
email: string
error: string
mode: 'login' | 'register'
pending: boolean
username: string
}>()
const emit = defineEmits<{
camera: []
gallery: []
submit: []
'update:mode': [value: 'login' | 'register']
'update:username': [value: string]
}>()
const phone = usePhoneStore()
</script>
<template>
<AppProfileAuth
:avatar-url="avatarUrl"
:body="phone.t('Apps.citymarkt.authBody')"
:camera-label="phone.t('Apps.citymarkt.takePhoto')"
:email="email"
:email-label="phone.t('Apps.citymarkt.profileEmail')"
:error="error"
:eyebrow="phone.t('Apps.citymarkt.authEyebrow')"
:gallery-label="phone.t('Apps.citymarkt.chooseGallery')"
:login-label="phone.t('Apps.citymarkt.login')"
:mode="mode"
:pending="pending"
:register-label="phone.t('Apps.citymarkt.register')"
:title="phone.t('Apps.citymarkt.authTitle')"
:username="username"
:username-label="phone.t('Apps.citymarkt.authUsername')"
@camera="emit('camera')"
@gallery="emit('gallery')"
@submit="emit('submit')"
@update:mode="emit('update:mode', $event)"
@update:username="emit('update:username', $event)"
/>
</template>
+13
View File
@@ -58,6 +58,19 @@ describe('account store', () => {
expect(account.devices).toEqual(devices)
})
it('clears the linked account after logout', async () => {
mockNuiCall.mockResolvedValueOnce({ success: true })
const account = useAccountStore()
account.hydrate({ devices, email: 'alex@ifruit.com' })
const success = await account.logout()
expect(success).toBe(true)
expect(account.email).toBe('')
expect(account.devices).toEqual([])
expect(mockNuiCall).toHaveBeenCalledWith('account:logout')
})
it('clears account state after a factory reset', async () => {
mockNuiCall.mockResolvedValueOnce({ success: true })
+53
View File
@@ -0,0 +1,53 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const saveDeviceNamespace = vi.fn()
vi.mock('@/stores/phone', () => ({
usePhoneStore: () => ({ saveDeviceNamespace }),
}))
import { useAppAuthStore } from '@/stores/app-auth'
describe('app auth store', () => {
beforeEach(() => {
setActivePinia(createPinia())
saveDeviceNamespace.mockReset()
})
it('keeps each app session independent', () => {
const auth = useAppAuthStore()
auth.hydrate(
{
accountEmail: 'demo@ifruit.com',
signedIn: ['citymarkt', 'feather'],
version: 1,
},
'demo@ifruit.com',
)
auth.signOut('citymarkt')
expect(auth.isSignedIn('citymarkt')).toBe(false)
expect(auth.isSignedIn('feather')).toBe(true)
expect(saveDeviceNamespace).toHaveBeenLastCalledWith('appAuth', {
accountEmail: 'demo@ifruit.com',
signedIn: ['feather'],
version: 1,
})
})
it('does not restore sessions belonging to another iFruit account', () => {
const auth = useAppAuthStore()
auth.hydrate(
{
accountEmail: 'old@ifruit.com',
signedIn: ['citymarkt'],
version: 1,
},
'new@ifruit.com',
)
expect(auth.isSignedIn('citymarkt')).toBe(false)
})
})
+78
View File
@@ -0,0 +1,78 @@
import { defineStore } from 'pinia'
import { usePhoneStore } from '@/stores/phone'
export const APP_AUTH_IDS = [
'citymarkt',
'local-pages',
'feather',
'crewlink',
] as const
export type AppAuthId = (typeof APP_AUTH_IDS)[number]
type PersistedAppAuth = {
accountEmail: string
signedIn: AppAuthId[]
version: 1
}
function emptySessions(): Record<AppAuthId, boolean> {
return {
citymarkt: false,
'local-pages': false,
feather: false,
crewlink: false,
}
}
export const useAppAuthStore = defineStore('app-auth', {
state: () => ({
accountEmail: '',
sessions: emptySessions(),
}),
actions: {
hydrate(payload: unknown, accountEmail: string): void {
this.accountEmail = accountEmail
this.sessions = emptySessions()
if (!accountEmail || !payload || typeof payload !== 'object') return
const data = payload as Partial<PersistedAppAuth>
if (
data.version !== 1 ||
data.accountEmail !== accountEmail ||
!Array.isArray(data.signedIn)
)
return
for (const appId of data.signedIn) {
if (APP_AUTH_IDS.includes(appId)) this.sessions[appId] = true
}
},
isSignedIn(appId: AppAuthId): boolean {
return Boolean(this.accountEmail && this.sessions[appId])
},
signIn(appId: AppAuthId, accountEmail: string): void {
if (this.accountEmail !== accountEmail) this.sessions = emptySessions()
this.accountEmail = accountEmail
this.sessions[appId] = true
this.persist()
},
signOut(appId: AppAuthId): void {
this.sessions[appId] = false
this.persist()
},
clear(): void {
this.accountEmail = ''
this.sessions = emptySessions()
this.persist()
},
persist(): void {
usePhoneStore().saveDeviceNamespace('appAuth', {
accountEmail: this.accountEmail,
signedIn: APP_AUTH_IDS.filter((appId) => this.sessions[appId]),
version: 1,
} satisfies PersistedAppAuth)
},
},
})
+17 -10
View File
@@ -62,19 +62,28 @@ export const useCrewLinkStore = defineStore('crewlink', {
}
return response
},
createProfile(username: string): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:create-profile', { username })
createProfile(
username: string,
avatarMediaId = 0,
): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:create-profile', {
avatarMediaId,
username,
})
},
updateProfile(
username: string,
mapVisible: boolean,
overheadVisible: boolean,
avatarMediaId?: number | null,
): Promise<NuiResponse<CrewLinkBootstrap>> {
return this.request('crewlink:update-profile', {
const data: Record<string, unknown> = {
mapVisible,
overheadVisible,
username,
})
}
if (avatarMediaId !== undefined) data.avatarMediaId = avatarMediaId
return this.request('crewlink:update-profile', data)
},
createGroup(
name: string,
@@ -107,17 +116,15 @@ export const useCrewLinkStore = defineStore('crewlink', {
return this.request('crewlink:join-code', { code })
},
rotateCode(groupId: string): Promise<NuiResponse<{ inviteCode: string }>> {
return nuiCall<{ inviteCode: string }>('crewlink:rotate-code', { groupId })
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,
)
return this.request('crewlink:invite-nearby', { targetSource }, false)
},
respondInvite(
invitationId: string,
+32
View File
@@ -0,0 +1,32 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { usePhoneStore } from '@/stores/phone'
describe('phone locale fallback', () => {
beforeEach(() => {
vi.stubGlobal('window', {
matchMedia: vi.fn(() => ({ matches: false })),
})
setActivePinia(createPinia())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('keeps CityMarkt profile copy translated with a partial server locale', () => {
const phone = usePhoneStore()
phone.open({ locales: { Apps: { citymarkt: { name: 'CityMarkt' } } } })
expect(phone.t('Apps.citymarkt.editProfile')).toBe('Edit profile')
expect(phone.t('Apps.citymarkt.profileIntro')).toBe(
'Your iFruit email stays linked to this profile.',
)
expect(phone.t('Apps.citymarkt.saveProfile')).toBe('Save profile')
expect(phone.t('Apps.citymarkt.addFavorite')).toBe('Add to favorites')
expect(phone.t('Apps.citymarkt.removeFavorite')).toBe(
'Remove from favorites',
)
})
})
+112 -25
View File
@@ -421,6 +421,26 @@ const defaultLocales: LocaleTree = {
signInBody:
'CrewLink uses your private iFruit identity to keep groups and roles available across your phones.',
openSettings: 'Open iFruit Settings',
authEyebrow: 'Private crew network',
authTitle: 'Welcome to CrewLink',
authBody:
'Your iFruit email is linked automatically. Use your CrewLink username to continue.',
login: 'Log in',
register: 'Register',
ifruitEmail: 'iFruit address',
gallery: 'Gallery',
camera: 'Camera',
backToLogin: 'Back to CrewLink login',
authErrors: {
no_ifruit_account: 'Sign in to your iFruit account in Settings first.',
invalid_username: 'Use 320 letters, numbers, dots, or underscores.',
profile_not_found: 'No CrewLink profile exists for this iFruit email.',
profile_exists: 'This iFruit email already has a CrewLink profile.',
username_taken: 'That CrewLink username is already taken.',
invalid_profile_image: 'Choose a valid photo from this phone.',
rate_limited: 'Too many attempts. Try again shortly.',
request_failed: 'CrewLink could not complete the request.',
},
welcomeEyebrow: 'Your crew. One signal.',
welcomeTitle: 'Find your people',
welcomeBody:
@@ -553,6 +573,9 @@ const defaultLocales: LocaleTree = {
externalApiBody: 'Approved scripts may add temporary group pings.',
editUsername: 'Edit Username',
editUsernameBody: 'Your username is unique across CrewLink.',
editProfile: 'Edit CrewLink Profile',
editProfileBody: 'Change your username and profile photo.',
removeProfilePhoto: 'Remove profile photo',
profileSaved: 'CrewLink profile saved.',
deleteGroup: 'Delete Group',
leaveGroup: 'Leave Group',
@@ -592,6 +615,7 @@ const defaultLocales: LocaleTree = {
profile_required: 'Create your CrewLink profile first.',
invalid_username: 'Use 320 letters, numbers, dots, or underscores.',
invalid_profile: 'Check your profile details.',
invalid_profile_image: 'Choose a valid photo from this phone.',
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.',
@@ -1003,14 +1027,16 @@ const defaultLocales: LocaleTree = {
done: 'Done',
authEyebrow: 'Feather network',
authWelcome: 'Welcome to Feather',
authBody: 'One iFruit account. Every conversation, wherever you sign in.',
authBody:
'Your iFruit email is linked automatically. Use your Feather username to continue.',
login: 'Log in',
register: 'Register',
loginTitle: 'Good to see you again',
loginBody: 'Log in with your iFruit address to continue to Feather.',
registerTitle: 'Create your iFruit account',
loginBody:
'Use your Feather username. Your iFruit email is linked automatically.',
registerTitle: 'Create your Feather profile',
registerBody:
'Choose an address and secure it with a password. Your Feather profile comes next.',
'Choose a username and optional profile photo. No password is needed.',
email: 'iFruit address',
emailPlaceholder: 'yourname',
password: 'Password',
@@ -1020,23 +1046,25 @@ const defaultLocales: LocaleTree = {
showPassword: 'Show password',
hidePassword: 'Hide password',
loginAction: 'Continue to Feather',
registerAction: 'Create account',
registerAction: 'Create profile',
noAccount: 'New to Feather?',
haveAccount: 'Already registered?',
registerNow: 'Create an account',
registerNow: 'Create a profile',
loginNow: 'Log in',
authTrust:
'Your credentials are verified by the server and this phone is linked to your iFruit account.',
'This Feather session is separate from your other apps and linked to your iFruit email.',
profileStep: 'Step 2 of 2',
accountConnected: 'iFruit account connected',
authErrors: {
invalid_email: 'Enter a valid 332 character iFruit address.',
invalid_password: 'Password must be 664 characters.',
password_mismatch: 'The passwords do not match.',
invalid_credentials: 'The iFruit address or password is incorrect.',
email_taken: 'That iFruit address is already registered.',
no_ifruit_account: 'Sign in to your iFruit account in Settings first.',
invalid_handle: 'Use 330 letters, numbers or underscores.',
invalid_username: 'That username does not match your Feather profile.',
profile_not_found: 'No Feather profile exists for this iFruit email.',
already_registered: 'This iFruit email already has a Feather profile.',
handle_taken: 'That Feather username is already taken.',
invalid_media: 'Choose a valid photo from this phone.',
rate_limited: 'Too many attempts. Try again in a minute.',
default: 'The account request failed. Please try again.',
default: 'Feather could not complete the request. Please try again.',
},
welcome: 'Find your voice',
welcomeBody:
@@ -2537,13 +2565,38 @@ const defaultLocales: LocaleTree = {
photos: 'photos',
activeListings: 'active listings',
signInTitle: 'Sign in to iFruit',
signInBody:
'Use Settings to sign in before selling, saving or messaging.',
signInBody: 'Log in to your CityMarkt profile to sell, save or message.',
authEyebrow: 'CityMarkt account',
authTitle: 'Welcome to CityMarkt',
authBody:
'Your iFruit email is linked automatically. Use your CityMarkt username to continue.',
login: 'Login',
register: 'Register',
authUsername: 'Username',
authErrors: {
no_ifruit_account: 'Connect an iFruit account in Settings first.',
invalid_username: 'Enter the username of your CityMarkt profile.',
profile_not_found: 'No CityMarkt profile exists for this iFruit email.',
profile_exists:
'A CityMarkt profile already exists. Use Login instead.',
},
noMessages: 'No conversations',
noMessagesBody: 'Messages about offers will appear here.',
myListings: 'My listings',
favorites: 'Favorites',
addFavorite: 'Add to favorites',
removeFavorite: 'Remove from favorites',
noProfileListings: 'Nothing here yet',
createProfile: 'Create your CityMarkt profile',
editProfile: 'Edit profile',
profileIntro: 'Your iFruit email stays linked to this profile.',
profileEmail: 'iFruit email',
displayName: 'Display name',
profileBio: 'About you',
saveProfile: 'Save profile',
cancel: 'Cancel',
profileSaved: 'Your profile was saved.',
removeProfilePhoto: 'Remove photo',
phone: 'Phone',
contactSeller: 'Contact seller',
messagePlaceholder: 'Hi, is this still available?',
@@ -2683,13 +2736,16 @@ const defaultLocales: LocaleTree = {
signInBody: 'Sign in to iFruit in Settings to publish and save posts.',
authEyebrow: 'Local Pages account',
authWelcome: 'Welcome to Local Pages',
authBody: 'Sign in or create an iFruit account to build your local profile.',
authBody:
'Your iFruit email is linked automatically. Use your Local Pages username to continue.',
login: 'Sign in',
register: 'Register',
loginTitle: 'Continue with iFruit',
loginBody: 'Your posts, saved items and profile stay linked to your account.',
loginBody:
'Your posts, saved items and profile stay linked to your account.',
registerTitle: 'Create an iFruit account',
registerBody: 'Choose your new iFruit address. Your Local Pages profile comes next.',
registerBody:
'Choose your new iFruit address. Your Local Pages profile comes next.',
authEmail: 'iFruit address',
authEmailPlaceholder: 'your.name',
authPassword: 'Password',
@@ -2705,7 +2761,8 @@ const defaultLocales: LocaleTree = {
profileEmail: 'iFruit email',
profileHandle: 'Username',
profileHandlePlaceholder: 'your.name',
profileHandleHint: 'Use 324 lowercase letters, numbers, dots or underscores.',
profileHandleHint:
'Use 324 lowercase letters, numbers, dots or underscores.',
profileBio: 'Bio',
profileBioPlaceholder: 'Tell the city a little about yourself...',
profilePhoto: 'Profile photo',
@@ -2751,13 +2808,17 @@ const defaultLocales: LocaleTree = {
'Review the listing and publish it when you are ready.',
cityMarktPhotosHint: 'The CityMarkt listing photos will be included.',
authErrors: {
invalid_email: 'Enter a valid 332 character iFruit address.',
invalid_password: 'Use a password between 6 and 64 characters.',
invalid_credentials: 'The iFruit address or password is incorrect.',
email_taken: 'This iFruit address is already registered.',
password_mismatch: 'The passwords do not match.',
no_ifruit_account: 'Sign in to your iFruit account in Settings first.',
invalid_username:
'Use 324 lowercase letters, numbers, dots or underscores.',
profile_not_found:
'No Local Pages profile exists for this iFruit email.',
profile_exists: 'This iFruit email already has a Local Pages profile.',
invalid_profile: 'Check your Local Pages username.',
invalid_profile_image: 'Choose a valid photo from this phone.',
profile_handle_taken: 'This Local Pages username is already taken.',
rate_limited: 'Too many attempts. Try again shortly.',
default: 'The iFruit account request failed.',
default: 'Local Pages could not complete the request.',
},
errors: {
profile_required: 'Create your Local Pages profile first.',
@@ -3469,6 +3530,32 @@ const defaultLocales: LocaleTree = {
send: 'Send',
start: 'Start',
stop: 'Stop',
signOut: 'Sign Out',
signingOut: 'Signing Out...',
signOutTitle: 'Sign out of {app}?',
signOutBody:
'You will only be signed out of {app}. Your other iFruit apps stay signed in.',
signOutFailed: 'Could not sign out. Please try again.',
appAuth: {
eyebrow: 'iFruit account',
title: 'Continue to {app}',
body: 'Use your iFruit email and password. This login applies only to {app}.',
login: 'Login',
register: 'Register',
email: 'iFruit email',
password: 'Password',
confirm: 'Confirm password',
loginAction: 'Log in',
registerAction: 'Create account',
errors: {
invalid_email: 'Enter a valid iFruit email.',
invalid_password: 'Password must be 664 characters.',
invalid_credentials: 'Email or password is incorrect.',
email_taken: 'That iFruit email is already registered.',
rate_limited: 'Too many attempts. Try again in a minute.',
default: 'The account request failed.',
},
},
use: 'Use',
},
Notifications: {
+4 -6
View File
@@ -15,15 +15,12 @@ export type CrewLinkColour =
| 'green'
| 'rose'
export type CrewLinkPingType =
| 'meeting'
| 'danger'
| 'help'
| 'target'
| 'info'
export type CrewLinkPingType = 'meeting' | 'danger' | 'help' | 'target' | 'info'
export type CrewLinkProfile = {
activeGroupId: string | null
avatarMediaId: number | null
avatarUrl: string | null
id: string
mapVisible: boolean
overheadVisible: boolean
@@ -31,6 +28,7 @@ export type CrewLinkProfile = {
}
export type CrewLinkMember = {
avatarUrl?: string | null
coords?: MapPoint & { z: number }
id: string
joinedAt: number
+125 -8
View File
@@ -18,6 +18,7 @@ import {
Inbox,
Laptop,
LayoutGrid,
LogOut,
MapPin,
MessageCircle,
MoreHorizontal,
@@ -50,7 +51,10 @@ import { useRoute, useRouter } from 'vue-router'
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
import CityMarktOfferCard from '@/components/citymarkt/CityMarktOfferCard.vue'
import CityMarktAuth from '@/components/citymarkt/CityMarktAuth.vue'
import AccountLogoutDialog from '@/components/account/AccountLogoutDialog.vue'
import { useAccountStore } from '@/stores/account'
import { useAppAuthStore } from '@/stores/app-auth'
import { useAppStoreStore } from '@/stores/app-store'
import { useEasyShareStore } from '@/stores/easyshare'
import { useMarketplaceStore } from '@/stores/marketplace'
@@ -105,7 +109,10 @@ type MediaContext = {
photos: SelectedPhoto[]
sellStep: number
}
type ProfileMediaContext = { draft: MarketplaceProfileDraft }
type ProfileMediaContext = {
authMode?: 'login' | 'register'
draft: MarketplaceProfileDraft
}
const phone = usePhoneStore()
const detailActionColors = computed(() => ({
@@ -115,11 +122,15 @@ const detailActionColors = computed(() => ({
const route = useRoute()
const router = useRouter()
const account = useAccountStore()
const appAuth = useAppAuthStore()
const appStore = useAppStoreStore()
const easyShare = useEasyShareStore()
const marketplace = useMarketplaceStore()
const messageMedia = useMessageMediaStore()
const pages = usePagesStore()
const logoutDialogOpen = ref(false)
const authMode = ref<'login' | 'register'>('login')
const authError = ref('')
const tab = ref<Tab>('discover')
const screen = ref<Screen>('main')
const selectedListing = ref<MarketplaceListing | null>(null)
@@ -239,7 +250,7 @@ const tabs = [
{ icon: UserRound, id: 'profile' },
] as const
const isAuthenticated = computed(() => account.email !== '')
const isAuthenticated = computed(() => appAuth.isSignedIn('citymarkt'))
const localPagesInstalled = computed(
() =>
appStore.isInstalled('local-pages') &&
@@ -529,6 +540,80 @@ async function selectTab(next: Tab): Promise<void> {
}
}
async function finishAuthentication(): Promise<void> {
await marketplace.loadProfile()
syncProfileDraft()
profileEditing.value = !marketplace.profile?.exists
await Promise.all([
marketplace.loadOwn(),
marketplace.load({ favorites: true }),
])
tab.value = 'profile'
screen.value = 'main'
}
function switchAuthMode(mode: 'login' | 'register'): void {
authMode.value = mode
authError.value = ''
profileDraft.value.displayName =
mode === 'login'
? (marketplace.profile?.display_name ?? '')
: account.email.split('@')[0] ?? ''
if (mode === 'login') selectedProfilePhoto.value = null
}
async function submitCityMarktAuth(): Promise<void> {
const username = profileDraft.value.displayName.trim()
authError.value = ''
if (!account.email) {
authError.value = phone.t('Apps.citymarkt.authErrors.no_ifruit_account')
return
}
if (username.length < 2 || username.length > 40) {
authError.value = phone.t('Apps.citymarkt.authErrors.invalid_username')
return
}
profilePending.value = true
await marketplace.loadProfile()
if (authMode.value === 'login') {
profilePending.value = false
if (!marketplace.profile?.exists) {
authError.value = phone.t('Apps.citymarkt.authErrors.profile_not_found')
return
}
if (
marketplace.profile.display_name.trim().toLocaleLowerCase(phone.lang) !==
username.toLocaleLowerCase(phone.lang)
) {
authError.value = phone.t('Apps.citymarkt.authErrors.invalid_username')
return
}
} else {
if (marketplace.profile?.exists) {
profilePending.value = false
authError.value = phone.t('Apps.citymarkt.authErrors.profile_exists')
return
}
const response = await marketplace.saveProfile({
avatarMediaId:
selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId,
bio: '',
displayName: username,
})
profilePending.value = false
if (!response.success) {
authError.value = phone.t(
`Apps.citymarkt.errors.${response.error ?? 'default'}`,
)
return
}
}
appAuth.signIn('citymarkt', account.email)
await finishAuthentication()
}
async function openListing(
item: Pick<MarketplaceListingSummary, 'id'>,
): Promise<void> {
@@ -578,9 +663,12 @@ function openProfileMedia(app: 'camera' | 'photos'): void {
'photo',
'/apps/citymarkt?profileEdit=1',
1,
{ draft: { ...profileDraft.value } } satisfies ProfileMediaContext,
{
authMode: isAuthenticated.value ? undefined : authMode.value,
draft: { ...profileDraft.value },
} satisfies ProfileMediaContext,
)
void router.push(app === 'photos' ? '/apps/photos?picker=1' : '/apps/camera?picker=1')
void router.push(`/apps/${app}?mediaAttachment=photo`)
}
function removeProfilePhoto(): void {
@@ -905,11 +993,13 @@ onMounted(async () => {
}
if (profileSelection?.context) {
profileDraft.value = profileSelection.context.draft
if (profileSelection.context.authMode)
authMode.value = profileSelection.context.authMode
if (profileSelection.media[0]) {
selectedProfilePhoto.value = profileSelection.media[0]
profileDraft.value.avatarMediaId = profileSelection.media[0].id
}
profileEditing.value = true
profileEditing.value = isAuthenticated.value
tab.value = 'profile'
screen.value = 'main'
}
@@ -932,6 +1022,8 @@ onMounted(async () => {
}
await marketplace.loadCounts()
} else {
await marketplace.loadProfile()
switchAuthMode(marketplace.profile?.exists ? 'login' : 'register')
tab.value = 'profile'
screen.value = 'main'
}
@@ -1127,6 +1219,9 @@ onMounted(async () => {
<UserRound :size="40" />
<h2>{{ phone.t('Apps.citymarkt.signInTitle') }}</h2>
<p>{{ phone.t('Apps.citymarkt.signInBody') }}</p>
<k-button rounded @click="tab = 'profile'">
{{ phone.t('Apps.citymarkt.login') }}
</k-button>
</div>
<div v-else-if="!marketplace.inquiries.length" class="citymarkt__empty">
<MessageCircle :size="36" /><strong>{{
@@ -1156,9 +1251,17 @@ onMounted(async () => {
<template v-else-if="tab === 'profile'">
<div v-if="!isAuthenticated" class="citymarkt__auth">
<UserRound :size="40" />
<h2>{{ phone.t('Apps.citymarkt.signInTitle') }}</h2>
<p>{{ phone.t('Apps.citymarkt.signInBody') }}</p>
<CityMarktAuth
v-model:mode="authMode"
v-model:username="profileDraft.displayName"
:avatar-url="selectedProfilePhoto?.url ?? null"
:email="account.email"
:error="authError"
:pending="profilePending"
@camera="openProfileMedia('camera')"
@gallery="openProfileMedia('photos')"
@submit="submitCityMarktAuth"
/>
</div>
<template v-else>
<section v-if="profileEditing || !marketplace.profile?.exists" class="citymarkt__profile-editor">
@@ -1255,6 +1358,10 @@ onMounted(async () => {
></k-glass>
</div>
</template>
<k-button large rounded outline class="citymarkt__logout" @click="logoutDialogOpen = true">
<LogOut :size="17" />
{{ phone.t('Common.signOut') }}
</k-button>
</template>
</template>
</section>
@@ -1849,6 +1956,11 @@ onMounted(async () => {
</k-tabbar-link>
</k-toolbar-pane>
</k-tabbar>
<AccountLogoutDialog
v-model:opened="logoutDialogOpen"
app-id="citymarkt"
:app-name="phone.t('Apps.citymarkt.name')"
/>
<Transition name="toast"
><div v-if="feedback" class="citymarkt__toast">
{{ feedback }}
@@ -2503,6 +2615,11 @@ onMounted(async () => {
.citymarkt__profile-actions button:disabled {
opacity: .4;
}
.citymarkt__logout {
width: 100%;
margin-top: 12px;
color: #ff796f;
}
:global(.citymarkt--light) .citymarkt__profile-editor {
border-color: #00000012;
}
File diff suppressed because it is too large Load Diff
+221 -235
View File
@@ -9,20 +9,16 @@ import {
CheckCircle2,
ChevronLeft,
ChevronRight,
Eye,
EyeOff,
Feather,
Home,
ImagePlus,
Images,
KeyRound,
Mail,
LogOut,
MessageCircle,
PencilLine,
Plus,
Search,
Share2,
ShieldCheck,
Trash2,
UserPlus,
UserMinus,
@@ -59,18 +55,16 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import FeatherPostCard from '@/components/feather/FeatherPostCard.vue'
import AccountLogoutDialog from '@/components/account/AccountLogoutDialog.vue'
import AppProfileAuth from '@/components/account/AppProfileAuth.vue'
import { useAccountStore } from '@/stores/account'
import { useAppAuthStore } from '@/stores/app-auth'
import { useFeatherStore } from '@/stores/feather'
import { useEasyShareStore } from '@/stores/easyshare'
import type { FeatherConnectionMode } from '@/stores/feather'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import type { FeatherMedia, FeatherPost, FeatherProfile } from '@/types/feather'
import {
filterMailAddressInput,
MAIL_ADDRESS_INPUT_MAX_LENGTH,
normalizeMailAddress,
} from '@/utils/mail'
type Tab = 'home' | 'explore' | 'network' | 'activity' | 'profile'
type Screen =
@@ -93,9 +87,15 @@ type ProfileMediaContext = {
editing: { bio: string; displayName: string }
selectedPhoto: SelectedPhoto | null
}
type AuthMediaContext = {
mode: 'login' | 'register'
selectedPhoto: SelectedPhoto | null
username: string
}
const phone = usePhoneStore()
const account = useAccountStore()
const appAuth = useAppAuthStore()
const feather = useFeatherStore()
const messageMedia = useMessageMediaStore()
const route = useRoute()
@@ -107,6 +107,7 @@ const activityView = ref<ActivityView>('all')
const profileView = ref<ProfileView>('posts')
const connectionMode = ref<FeatherConnectionMode>('followers')
const settingsOpen = ref(false)
const logoutDialogOpen = ref(false)
const compactMode = ref(false)
const showSuggestions = ref(true)
const search = ref('')
@@ -128,11 +129,10 @@ const onboarding = ref({ bio: '', displayName: '', handle: '' })
const editing = ref({ bio: '', displayName: '' })
const selectedProfilePhoto = ref<SelectedPhoto | null>(null)
const authMode = ref<'login' | 'register'>('login')
const authForm = ref({ confirm: '', email: '', password: '' })
const authUsername = ref('')
const authProfilePhoto = ref<SelectedPhoto | null>(null)
const authBusy = ref(false)
const authAttempted = ref(false)
const authError = ref('')
const authPasswordVisible = ref(false)
const composeFabColors = {
activeBgIos: 'active:!bg-[#2778dc] dark:active:!bg-[#2778dc]',
bgIos: '!bg-[#58a6ff] dark:!bg-[#58a6ff]',
@@ -244,18 +244,9 @@ const navbarTitle = computed(() => {
const unreadActivities = computed(
() => feather.activities.filter((item) => !item.read).length,
)
const authEmailValid = computed(
() => normalizeMailAddress(authForm.value.email) !== null,
)
const authPasswordValid = computed(() => {
const length = authForm.value.password.length
return length >= 6 && length <= 64
})
const authConfirmValid = computed(
() =>
authMode.value === 'login' ||
(authForm.value.confirm.length > 0 &&
authForm.value.confirm === authForm.value.password),
const isAuthenticated = computed(() => appAuth.isSignedIn('feather'))
const authUsernameValid = computed(() =>
/^[a-z0-9][a-z0-9_]{1,28}[a-z0-9]$/i.test(authUsername.value.trim()),
)
function t(path: string, params?: Record<string, string>): string {
@@ -303,13 +294,6 @@ function errorToast(error?: string): void {
toast(key)
}
function updateAuthEmail(event: Event): void {
const input = event.target as HTMLInputElement
const filtered = filterMailAddressInput(input.value)
if (input.value !== filtered) input.value = filtered
authForm.value.email = filtered
}
function inputValue(event: Event): string {
const target = event.target
if (
@@ -324,55 +308,101 @@ function inputValue(event: Event): string {
function switchAuthMode(mode: 'login' | 'register'): void {
authMode.value = mode
authForm.value.confirm = ''
authProfilePhoto.value = null
if (mode === 'register') {
authUsername.value = (account.email.split('@')[0] ?? '')
.replace(/[^a-z0-9_]/gi, '_')
.slice(0, 30)
} else {
authUsername.value = ''
}
authError.value = ''
authAttempted.value = false
}
function authErrorMessage(error?: string): string {
const known = [
'invalid_email',
'invalid_password',
'invalid_credentials',
'email_taken',
'already_registered',
'handle_taken',
'invalid_handle',
'invalid_media',
'invalid_username',
'no_ifruit_account',
'profile_not_found',
'rate_limited',
]
return t(`authErrors.${error && known.includes(error) ? error : 'default'}`)
}
async function submitAuth(): Promise<void> {
authAttempted.value = true
authError.value = ''
if (!authEmailValid.value) {
authError.value = t('authErrors.invalid_email')
if (!account.email) {
authError.value = t('authErrors.no_ifruit_account')
return
}
if (!authPasswordValid.value) {
authError.value = t('authErrors.invalid_password')
return
}
if (!authConfirmValid.value) {
authError.value = t('authErrors.password_mismatch')
if (!authUsernameValid.value) {
authError.value = t('authErrors.invalid_handle')
return
}
const email = normalizeMailAddress(authForm.value.email)
if (!email) return
const username = authUsername.value.trim().toLowerCase()
authBusy.value = true
const response =
authMode.value === 'login'
? await account.login(email, authForm.value.password)
: await account.register(email, authForm.value.password)
authBusy.value = false
if (!response.success) {
authError.value = authErrorMessage(response.error)
const bootstrapped = await feather.bootstrap()
if (!bootstrapped) {
authBusy.value = false
authError.value = authErrorMessage()
return
}
if (authMode.value === 'login') {
authBusy.value = false
if (!feather.onboarded || !feather.profile) {
authError.value = t('authErrors.profile_not_found')
return
}
if (feather.profile.handle.toLowerCase() !== username) {
authError.value = t('authErrors.invalid_username')
return
}
} else {
if (feather.onboarded || feather.profile) {
authBusy.value = false
authError.value = t('authErrors.already_registered')
return
}
const response = await feather.createProfile({
avatarId: authProfilePhoto.value?.id,
bio: '',
displayName: authUsername.value.trim(),
handle: username,
})
authBusy.value = false
if (!response.success) {
authError.value = authErrorMessage(response.error)
return
}
}
authForm.value = { confirm: '', email: '', password: '' }
authAttempted.value = false
authPasswordVisible.value = false
await feather.bootstrap()
appAuth.signIn('feather', account.email)
authUsername.value = ''
authProfilePhoto.value = null
if (authMode.value === 'login') await feather.bootstrap()
}
function openAuthMedia(app: 'camera' | 'photos'): void {
messageMedia.begin(
'feather:auth-avatar',
'photo',
'/apps/feather?auth=register',
1,
{
mode: authMode.value,
selectedPhoto: authProfilePhoto.value,
username: authUsername.value,
} satisfies AuthMediaContext,
)
void router.push({
path: `/apps/${app}`,
query: { mediaAttachment: 'photo' },
})
}
async function createProfile(): Promise<void> {
@@ -738,8 +768,12 @@ onMounted(async () => {
window.addEventListener('keydown', handleMediaPreviewKeydown)
const selection =
messageMedia.consumeMany<ComposerContext>('feather:composer')
const profileSelection =
messageMedia.consumeMany<ProfileMediaContext>('feather:profile-avatar')
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
'feather:profile-avatar',
)
const authSelection = messageMedia.consumeMany<AuthMediaContext>(
'feather:auth-avatar',
)
if (selection) {
if (selection.context) {
composerBody.value = selection.context.body
@@ -763,6 +797,19 @@ onMounted(async () => {
}
}
}
if (authSelection) {
if (authSelection.context) {
authMode.value = authSelection.context.mode
authUsername.value = authSelection.context.username
authProfilePhoto.value = authSelection.context.selectedPhoto
}
if (authSelection.media[0]) {
authProfilePhoto.value = {
id: authSelection.media[0].id,
url: authSelection.media[0].url,
}
}
}
if (route.query.compose === '1') screen.value = 'composer'
await feather.bootstrap()
if (route.query.profileEdit === '1' && feather.profile) screen.value = 'edit'
@@ -790,18 +837,24 @@ onMounted(async () => {
component="main"
class="feather-app"
:class="{
'feather-app--active': feather.onboarded,
'feather-app--active': feather.onboarded && isAuthenticated,
'feather-app--home':
feather.onboarded && screen === 'main' && tab === 'home',
'feather-app--light': feather.onboarded && !phone.isDarkMode,
feather.onboarded &&
isAuthenticated &&
screen === 'main' &&
tab === 'home',
'feather-app--light': !phone.isDarkMode,
'feather-app--section':
feather.onboarded && screen === 'main' && tab !== 'home',
'native-app': feather.onboarded,
feather.onboarded &&
isAuthenticated &&
screen === 'main' &&
tab !== 'home',
'native-app': feather.onboarded && isAuthenticated,
'feather-app--compact': compactMode,
}"
>
<kNavbar
v-if="feather.onboarded"
v-if="feather.onboarded && isAuthenticated"
class="feather-navbar"
:left-class="
screen === 'composer' ? 'feather-navbar__plain-action' : undefined
@@ -874,150 +927,31 @@ onMounted(async () => {
<span>{{ t('loading') }}</span>
</div>
<section v-else-if="!account.email" class="feather-auth">
<header class="feather-auth__hero">
<div class="feather-welcome__mark"><Feather :size="43" /></div>
<div>
<span>{{ t('authEyebrow') }}</span>
<h1>{{ t('authWelcome') }}</h1>
<p>{{ t('authBody') }}</p>
</div>
</header>
<form class="feather-auth__card" @submit.prevent="submitAuth">
<kSegmented raised class="feather-auth__modes">
<kSegmentedButton
type="button"
:active="authMode === 'login'"
@click="switchAuthMode('login')"
>
{{ t('login') }}
</kSegmentedButton>
<kSegmentedButton
type="button"
:active="authMode === 'register'"
@click="switchAuthMode('register')"
>
{{ t('register') }}
</kSegmentedButton>
</kSegmented>
<div class="feather-auth__copy">
<h2>
{{ t(authMode === 'login' ? 'loginTitle' : 'registerTitle') }}
</h2>
<p>{{ t(authMode === 'login' ? 'loginBody' : 'registerBody') }}</p>
</div>
<kList strong inset class="feather-auth__fields">
<kListInput
class="relative"
:value="authForm.email"
:label="t('email')"
:placeholder="t('emailPlaceholder')"
:maxlength="MAIL_ADDRESS_INPUT_MAX_LENGTH"
autocomplete="username"
autocapitalize="none"
autocorrect="off"
inputmode="email"
spellcheck="false"
:input-class="!authForm.email.includes('@') ? 'pr-20' : undefined"
:error="
authAttempted && !authEmailValid
? t('authErrors.invalid_email')
: ''
"
@input="updateAuthEmail"
>
<template #media><Mail :size="19" /></template>
<span
v-if="!authForm.email.includes('@')"
class="feather-auth__domain"
>@ifruit.com</span
>
</kListInput>
<kListInput
:value="authForm.password"
class="relative"
:type="authPasswordVisible ? 'text' : 'password'"
:label="t('password')"
:placeholder="t('passwordPlaceholder')"
:maxlength="64"
:autocomplete="
authMode === 'login' ? 'current-password' : 'new-password'
"
:error="
authAttempted && !authPasswordValid
? t('authErrors.invalid_password')
: ''
"
@input="authForm.password = inputValue($event)"
>
<template #media><KeyRound :size="19" /></template>
<button
class="feather-auth__reveal"
type="button"
:aria-label="
t(authPasswordVisible ? 'hidePassword' : 'showPassword')
"
@click="authPasswordVisible = !authPasswordVisible"
>
<EyeOff v-if="authPasswordVisible" :size="18" />
<Eye v-else :size="18" />
</button>
</kListInput>
<kListInput
v-if="authMode === 'register'"
:value="authForm.confirm"
:type="authPasswordVisible ? 'text' : 'password'"
:label="t('confirmPassword')"
:placeholder="t('confirmPasswordPlaceholder')"
:maxlength="64"
autocomplete="new-password"
:error="
authAttempted && !authConfirmValid
? t('authErrors.password_mismatch')
: ''
"
@input="authForm.confirm = inputValue($event)"
>
<template #media><ShieldCheck :size="19" /></template>
</kListInput>
</kList>
<div v-if="authError" class="feather-auth__error" role="alert">
{{ authError }}
</div>
<kButton
component="button"
type="submit"
large
rounded
:disabled="authBusy"
class="feather-primary feather-auth__submit"
>
<kPreloader v-if="authBusy" />
<template v-else>
{{ t(authMode === 'login' ? 'loginAction' : 'registerAction') }}
</template>
</kButton>
<p class="feather-auth__switch">
{{ t(authMode === 'login' ? 'noAccount' : 'haveAccount') }}
<button
type="button"
@click="switchAuthMode(authMode === 'login' ? 'register' : 'login')"
>
{{ t(authMode === 'login' ? 'registerNow' : 'loginNow') }}
</button>
</p>
</form>
<div class="feather-auth__trust">
<ShieldCheck :size="16" />
<span>{{ t('authTrust') }}</span>
</div>
<section v-else-if="!isAuthenticated" class="feather-auth">
<AppProfileAuth
:mode="authMode"
v-model:username="authUsername"
:avatar-url="authProfilePhoto?.url ?? null"
:body="t('authBody')"
:camera-label="t('takePhoto')"
:email="account.email"
:email-label="t('email')"
:error="authError"
:eyebrow="t('authEyebrow')"
:gallery-label="t('chooseGallery')"
:login-label="t('login')"
:max-username-length="30"
:min-username-length="3"
:pending="authBusy"
:register-label="t('register')"
:title="t('authWelcome')"
:username-label="t('handle')"
:username-placeholder="t('handlePlaceholder')"
@camera="openAuthMedia('camera')"
@gallery="openAuthMedia('photos')"
@submit="submitAuth"
@update:mode="switchAuthMode"
/>
</section>
<section v-else-if="!feather.onboarded" class="feather-onboarding">
@@ -1080,6 +1014,16 @@ onMounted(async () => {
>
{{ t('start') }}
</kButton>
<kButton
large
rounded
outline
class="feather-onboarding__logout"
@click="logoutDialogOpen = true"
>
<LogOut :size="16" />
{{ phone.t('Common.signOut') }}
</kButton>
</section>
<template v-else>
@@ -1234,7 +1178,6 @@ onMounted(async () => {
<template #media><AlignLeft :size="18" /></template>
</kListInput>
</kList>
</section>
<section
@@ -1516,6 +1459,15 @@ onMounted(async () => {
<PencilLine :size="15" />
<span>{{ t('editProfile') }}</span>
</kButton>
<kButton
rounded
outline
class="feather-profile-action feather-profile-action--logout"
@click="logoutDialogOpen = true"
>
<LogOut :size="15" />
<span>{{ phone.t('Common.signOut') }}</span>
</kButton>
</div>
</div>
</kGlass>
@@ -2084,6 +2036,12 @@ onMounted(async () => {
</kFab>
</template>
<AccountLogoutDialog
v-model:opened="logoutDialogOpen"
app-id="feather"
:app-name="t('name')"
/>
<kSheet :opened="settingsOpen" @backdropclick="settingsOpen = false">
<kBlock strong inset class="feather-settings-sheet">
<header>
@@ -2219,6 +2177,7 @@ onMounted(async () => {
.feather-app {
--feather-blue: #438cf5;
--feather-blue-dark: #2867d8;
--color-primary: var(--feather-blue);
background: #fff;
color: #111923;
}
@@ -2346,12 +2305,16 @@ onMounted(async () => {
box-shadow: 0 15px 35px rgb(45 111 224 / 25%);
}
.feather-auth {
--auth-accent: var(--feather-blue);
--panel: #18212b;
min-height: 100%;
overflow-y: auto;
padding: 25px 15px 30px;
padding: 68px 15px 34px;
color: #f4f7fa;
background:
radial-gradient(circle at 85% 2%, rgb(90 183 255 / 19%), transparent 34%),
radial-gradient(circle at 0 37%, rgb(67 140 245 / 9%), transparent 38%);
radial-gradient(circle at 85% 5%, rgb(90 183 255 / 18%), transparent 31%),
radial-gradient(circle at 0 42%, rgb(67 140 245 / 8%), transparent 36%),
#0f151b;
}
.feather-auth__hero {
display: flex;
@@ -2423,31 +2386,41 @@ onMounted(async () => {
.feather-auth__fields :deep(.k-list-item-media) {
color: var(--feather-blue);
}
.feather-auth__domain,
.feather-auth__reveal {
position: absolute;
top: 50%;
right: 16px;
transform: translateY(-50%);
.feather-auth__photo {
display: flex;
align-items: center;
gap: 10px;
margin: 2px 5px 10px;
text-align: left;
}
.feather-auth__domain {
pointer-events: none;
color: #7b8796;
font-size: 11px;
}
.feather-auth__reveal {
.feather-auth__photo > span {
display: grid;
width: 62px;
height: 62px;
flex: none;
place-items: center;
width: 30px;
height: 30px;
border: 0;
overflow: hidden;
border-radius: 50%;
color: #788493;
background: transparent;
}
.feather-auth__reveal:active {
color: var(--feather-blue);
background: rgb(67 140 245 / 10%);
}
.feather-auth__photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.feather-auth__photo > div {
display: grid;
min-width: 0;
flex: 1;
gap: 6px;
}
.feather-auth__photo :deep(.k-button) {
min-height: 34px;
justify-content: flex-start;
gap: 6px;
font-size: 11px;
}
.feather-auth__error {
margin: 0 4px 10px;
border-radius: 10px;
@@ -4351,6 +4324,19 @@ onMounted(async () => {
font-size: 10.5px;
font-weight: 750;
}
.feather-app--active
.feather-profile__actions
:deep(.feather-profile-action--logout) {
grid-column: 1 / -1;
border-color: color-mix(in srgb, #f04f65 62%, transparent);
color: #f04f65;
}
.feather-onboarding__logout {
width: 100%;
margin-top: 8px;
border-color: color-mix(in srgb, #f04f65 62%, transparent);
color: #f04f65;
}
.feather-app--active .feather-profile-action span {
min-width: 0;
overflow: hidden;
File diff suppressed because one or more lines are too long
+4
View File
@@ -57,6 +57,7 @@ import {
import { usePhoneStore } from '@/stores/phone'
import PhonePasscode from '@/components/PhonePasscode.vue'
import { useAccountStore } from '@/stores/account'
import { useAppAuthStore } from '@/stores/app-auth'
import type {
LaunchablePhoneAppDefinition,
LaunchablePhoneAppId,
@@ -132,6 +133,7 @@ const FRAME_PICKER_GAP = 8
const phone = usePhoneStore()
const account = useAccountStore()
const appAuth = useAppAuthStore()
const query = ref('')
const activeView = ref<SettingsView>('root')
const selectedNotificationAppId = ref<LaunchablePhoneAppId>('calculator')
@@ -602,6 +604,7 @@ async function submitAccount(): Promise<void> {
async function logoutAccount(): Promise<void> {
if (!(await account.logout())) accountToast.value = accountError()
else appAuth.clear()
}
function requestRemoveDevice(imei: string): void {
@@ -650,6 +653,7 @@ async function confirmFactoryReset(): Promise<void> {
factoryResetProgress.value = 100
factoryResetting.value = false
if (!success) accountToast.value = accountError()
else appAuth.hydrate(undefined, '')
}
async function confirmSimEject(): Promise<void> {
+168 -28
View File
@@ -144,6 +144,8 @@ let mockMapMarkers = [
]
let crewLinkProfile = {
activeGroupId: 'crewlink-group-night-shift',
avatarMediaId: 1,
avatarUrl: 'https://picsum.photos/seed/crewlink-skyline/240/240',
id: 'crewlink-profile-skyline',
mapVisible: true,
overheadVisible: false,
@@ -176,6 +178,7 @@ const crewLinkMembers = {
'crewlink-group-night-shift': [
{
coords: { x: -155.2, y: -1005.8, z: 28.4 },
avatarUrl: 'https://picsum.photos/seed/crewlink-skyline/240/240',
id: 'crewlink-profile-skyline',
joinedAt: Date.now() - 36 * 86400000,
mapVisible: true,
@@ -350,7 +353,10 @@ const crewLinkLimits = {
}
function crewLinkBootstrap(testScenario = '') {
if (testScenario === 'crewlink-onboarding') {
if (
testScenario === 'crewlink-onboarding' ||
(testScenario === 'crewlink-register' && !crewLinkProfile)
) {
return { groups: [], invitations: [], profile: null }
}
if (testScenario === 'crewlink-empty') {
@@ -1985,6 +1991,14 @@ let calendarEvents = [
},
]
const deviceData = {
appAuth: {
payload: {
accountEmail: 'demo@ifruit.com',
signedIn: ['citymarkt', 'local-pages', 'feather', 'crewlink'],
version: 1,
},
revision: 1,
},
alarms: {
payload: [
{
@@ -3881,7 +3895,8 @@ const easyShareCatalog = [
appId: 'citymarkt',
copyText: 'Comet Retro Custom in excellent condition.',
id: 'listing-easyshare-comet',
imageUrl: 'https://images.unsplash.com/photo-1503736334956-4c8f8e92946d?w=900',
imageUrl:
'https://images.unsplash.com/photo-1503736334956-4c8f8e92946d?w=900',
kind: 'link',
link: 'skyphone://citymarkt/listing/listing-easyshare-comet',
subtitle: '$84,000',
@@ -3905,7 +3920,8 @@ const easyShareCatalog = [
appId: 'photos',
copyText: 'Sunset over Los Santos.',
id: 3,
imageUrl: 'https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=900',
imageUrl:
'https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=900',
kind: 'photo',
link: 'skyphone://media/3',
title: 'Los Santos sunset',
@@ -3960,7 +3976,8 @@ const easyShareCatalog = [
appId: 'photos',
copyText: 'Vehicle walkaround video.',
id: 7,
imageUrl: 'https://videos.pexels.com/video-files/3130284/3130284-hd_1920_1080_30fps.mp4',
imageUrl:
'https://videos.pexels.com/video-files/3130284/3130284-hd_1920_1080_30fps.mp4',
kind: 'video',
link: 'skyphone://media/7',
title: 'Vehicle walkaround',
@@ -4453,6 +4470,10 @@ app.post('/api/:endpoint', (request, response) => {
}
crewLinkProfile = {
activeGroupId: null,
avatarMediaId: Number(request.body.avatarMediaId) || null,
avatarUrl:
mockMedia.find((item) => item.id === Number(request.body.avatarMediaId))
?.url ?? null,
id: `crewlink-profile-${Date.now()}`,
mapVisible: true,
overheadVisible: false,
@@ -4462,8 +4483,25 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'crewlink:update-profile') {
const hasAvatarUpdate = request.body.avatarMediaId !== undefined
const avatarMediaId = Number(request.body.avatarMediaId) || null
const avatar = hasAvatarUpdate
? mockMedia.find(
(item) => item.id === avatarMediaId && item.mediaType === 'photo',
)
: null
if (avatarMediaId && !avatar) {
response.json({ success: false, error: 'invalid_profile_image' })
return
}
crewLinkProfile = {
...crewLinkProfile,
avatarMediaId: hasAvatarUpdate
? avatarMediaId
: crewLinkProfile.avatarMediaId,
avatarUrl: hasAvatarUpdate
? (avatar?.url ?? null)
: crewLinkProfile.avatarUrl,
mapVisible: request.body.mapVisible === true,
overheadVisible: request.body.overheadVisible === true,
username: String(request.body.username ?? crewLinkProfile.username),
@@ -4471,6 +4509,7 @@ app.post('/api/:endpoint', (request, response) => {
for (const members of Object.values(crewLinkMembers)) {
const own = members.find((member) => member.id === crewLinkProfile.id)
if (own) {
own.avatarUrl = crewLinkProfile.avatarUrl
own.mapVisible = crewLinkProfile.mapVisible
own.overheadVisible = crewLinkProfile.overheadVisible
own.username = crewLinkProfile.username
@@ -4718,7 +4757,7 @@ app.post('/api/:endpoint', (request, response) => {
success: true,
data: {
onboarded: featherOnboarded,
profile: featherProfiles[0],
profile: featherOnboarded ? featherProfiles[0] : null,
feed: {
items: empty
? []
@@ -4764,6 +4803,22 @@ app.post('/api/:endpoint', (request, response) => {
featherProfiles[0].display_name = displayName
featherProfiles[0].handle = handle
featherProfiles[0].bio = bio
const avatar = mockMedia.find(
(item) =>
item.id === Number(request.body.avatarId) && item.mediaType === 'photo',
)
if (request.body.avatarId && !avatar) {
response.json({ success: false, error: 'invalid_media' })
return
}
featherProfiles[0].avatar_url = avatar?.url ?? null
featherPosts
.filter((post) => post.profile_id === featherProfiles[0].id)
.forEach((post) => {
post.avatar_url = featherProfiles[0].avatar_url
post.display_name = displayName
post.handle = handle
})
featherOnboarded = true
response.json({ success: true })
return
@@ -5388,8 +5443,7 @@ app.post('/api/:endpoint', (request, response) => {
mediaDurationMs: request.body.mediaDurationMs ?? null,
mediaUrl: messageType === 'text' ? null : mediaUrl,
messageType,
sharePayload:
messageType === 'share' ? request.body.sharePayload : null,
sharePayload: messageType === 'share' ? request.body.sharePayload : null,
}
flareMessages[match.id] ??= []
flareMessages[match.id].push(message)
@@ -6759,8 +6813,7 @@ app.post('/api/:endpoint', (request, response) => {
replyToId: request.body.replyToId,
replyBody: reply?.body,
reactions: {},
sharePayload:
messageType === 'share' ? request.body.sharePayload : null,
sharePayload: messageType === 'share' ? request.body.sharePayload : null,
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
readAt: null,
}
@@ -6995,15 +7048,69 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'development:bootstrap') {
if (testScenario === 'feather-onboarding') featherOnboarded = false
authenticated = true
linkedAccount = {
devices: accountDevices,
email: 'demo@ifruit.com',
id: 1,
}
if (
testScenario === 'feather-onboarding' ||
testScenario === 'feather-register'
)
featherOnboarded = false
else featherOnboarded = true
pagesOnboardingCompleted = ![
'local-pages-onboarding',
'local-pages-register',
'citymarkt-local-pages-account-missing',
].includes(testScenario)
if (testScenario === 'crewlink-register') {
crewLinkProfile = null
} else {
crewLinkProfile = {
activeGroupId: 'crewlink-group-night-shift',
avatarMediaId: 1,
avatarUrl: 'https://picsum.photos/seed/crewlink-skyline/240/240',
id: 'crewlink-profile-skyline',
mapVisible: true,
overheadVisible: false,
username: 'Skyline',
}
}
if (testScenario === 'citymarkt-register') {
marketplaceProfile = {
avatar_media_id: null,
avatar_url: null,
bio: '',
display_name: '',
email: linkedAccount?.email ?? 'demo@ifruit.com',
exists: false,
listing_count: 0,
}
} else {
marketplaceProfile = {
avatar_media_id: 1,
avatar_url: 'https://picsum.photos/seed/citymarkt-demo-avatar/240/240',
bio: 'Fair prices, quick replies, and meetups anywhere in Los Santos.',
display_name: 'Skyline Deals',
email: linkedAccount?.email ?? 'demo@ifruit.com',
exists: true,
listing_count: marketplaceListings.filter(
(listing) => listing.seller_account_id === 1,
).length,
}
}
response.json({
success: true,
data: {
account: testScenario === 'feather-login' ? null : linkedAccount,
account: linkedAccount,
device: {
data:
testScenario.startsWith('citymarkt-')
testScenario.startsWith('citymarkt-') ||
testScenario.startsWith('feather-') ||
testScenario.startsWith('local-pages-') ||
testScenario.startsWith('crewlink-')
? {
...deviceData,
apps: {
@@ -7012,10 +7119,11 @@ app.post('/api/:endpoint', (request, response) => {
...deviceData.apps.payload,
homeLayout: {
dock: [],
grid:
testScenario === 'citymarkt-local-pages-missing'
? []
: ['local-pages'],
grid: testScenario.startsWith('crewlink-')
? ['crewlink']
: testScenario === 'citymarkt-local-pages-missing'
? ['citymarkt']
: ['citymarkt', 'local-pages'],
hidden:
testScenario === 'citymarkt-local-pages-missing'
? ['local-pages']
@@ -7024,6 +7132,31 @@ app.post('/api/:endpoint', (request, response) => {
},
},
},
appAuth: [
'citymarkt-login',
'citymarkt-register',
'feather-login',
'feather-register',
'local-pages-login',
'local-pages-register',
'crewlink-login',
'crewlink-register',
].includes(testScenario)
? {
payload: {
accountEmail: linkedAccount?.email ?? '',
signedIn: testScenario.startsWith('feather-')
? ['citymarkt', 'local-pages', 'crewlink']
: testScenario.startsWith('local-pages-')
? ['citymarkt', 'feather', 'crewlink']
: testScenario.startsWith('crewlink-')
? ['citymarkt', 'local-pages', 'feather']
: ['local-pages', 'feather', 'crewlink'],
version: 1,
},
revision: deviceData.appAuth?.revision ?? 0,
}
: deviceData.appAuth,
}
: deviceData,
imei: '356938035643809',
@@ -7151,11 +7284,11 @@ app.post('/api/:endpoint', (request, response) => {
organization: selectedContact.organization ?? null,
phone_number: selectedContact.phone_number,
}
: messageType === 'share'
? request.body.sharePayload
: isAttachment
? attachmentId
: null,
: messageType === 'share'
? request.body.sharePayload
: isAttachment
? attachmentId
: null,
media_waveform:
messageType === 'voice' ? request.body.mediaWaveform : null,
message_type: messageType,
@@ -7575,9 +7708,11 @@ app.post('/api/:endpoint', (request, response) => {
if (endpoint === 'pages:profile') {
const email = linkedAccount?.email ?? pagesProfile.email
const onboarding =
['local-pages-onboarding', 'citymarkt-local-pages-account-missing'].includes(
testScenario,
) && !pagesOnboardingCompleted
[
'local-pages-onboarding',
'local-pages-register',
'citymarkt-local-pages-account-missing',
].includes(testScenario) && !pagesOnboardingCompleted
response.json({
success: true,
data: {
@@ -7597,9 +7732,12 @@ app.post('/api/:endpoint', (request, response) => {
if (endpoint === 'pages:profile-save') {
pagesOnboardingCompleted = true
const avatarMediaId = Number(request.body.avatarMediaId) || 0
const avatarMedia = avatarMediaId > 0
? mockMedia.find((item) => item.id === avatarMediaId && item.mediaType === 'photo')
: null
const avatarMedia =
avatarMediaId > 0
? mockMedia.find(
(item) => item.id === avatarMediaId && item.mediaType === 'photo',
)
: null
if (avatarMediaId > 0 && !avatarMedia) {
response.json({ success: false, error: 'invalid_profile_image' })
return
@@ -7610,7 +7748,9 @@ app.post('/api/:endpoint', (request, response) => {
bio: String(request.body.bio ?? '').trim(),
email: linkedAccount?.email ?? pagesProfile.email,
exists: true,
handle: String(request.body.handle ?? '').trim().toLowerCase(),
handle: String(request.body.handle ?? '')
.trim()
.toLowerCase(),
}
pagesPosts.forEach((post) => {
if (post.account_id === 1) post.author_name = pagesProfile.handle
+51 -14
View File
@@ -69,6 +69,18 @@ Locales["en"] = {
add = "Add", back = "Back", cancel = "Cancel", clear = "Clear", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause", use = "Use",
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop",
signOut = "Sign Out", signingOut = "Signing Out...", signOutTitle = "Sign out of {app}?",
signOutBody = "You will only be signed out of {app}. Your other iFruit apps stay signed in.", signOutFailed = "Could not sign out. Please try again.",
appAuth = {
eyebrow = "iFruit account", title = "Continue to {app}", body = "Use your iFruit email and password. This login applies only to {app}.",
login = "Login", register = "Register", email = "iFruit email", password = "Password", confirm = "Confirm password",
loginAction = "Log in", registerAction = "Create account",
errors = {
invalid_email = "Enter a valid iFruit email.", invalid_password = "Password must be 664 characters.",
invalid_credentials = "Email or password is incorrect.", email_taken = "That iFruit email is already registered.",
rate_limited = "Too many attempts. Try again in a minute.", default = "The account request failed.",
},
},
},
ControlCenter = {
airplaneMode = "Airplane Mode", bluetooth = "Bluetooth", brightness = "Brightness", calculator = "Calculator",
@@ -124,6 +136,15 @@ Locales["en"] = {
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",
authEyebrow = "Private crew network", authTitle = "Welcome to CrewLink",
authBody = "Your iFruit email is linked automatically. Use your CrewLink username to continue.",
login = "Log in", register = "Register", ifruitEmail = "iFruit address", gallery = "Gallery", camera = "Camera", backToLogin = "Back to CrewLink login",
authErrors = {
no_ifruit_account = "Sign in to your iFruit account in Settings first.", invalid_username = "Use 3-20 letters, numbers, dots, or underscores.",
profile_not_found = "No CrewLink profile exists for this iFruit email.", profile_exists = "This iFruit email already has a CrewLink profile.",
username_taken = "That CrewLink username is already taken.", invalid_profile_image = "Choose a valid photo from this phone.",
rate_limited = "Too many attempts. Try again shortly.", request_failed = "CrewLink could not complete the request.",
},
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.",
@@ -142,7 +163,8 @@ Locales["en"] = {
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",
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.",
editProfile = "Edit CrewLink Profile", editProfileBody = "Change your username and profile photo.", removeProfilePhoto = "Remove profile photo", 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." },
@@ -152,7 +174,7 @@ Locales["en"] = {
["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.",
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.", invalid_profile_image = "Choose a valid photo from this phone.", 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.",
@@ -264,19 +286,19 @@ Locales["en"] = {
name = "Feather", loading = "Loading Feather", home = "Home", explore = "Explore", activity = "Notifications", activityNav = "Alerts", profile = "Profile", back = "Back",
settings = "Settings", settingsEyebrow = "Feather preferences", compactMode = "Compact timeline", compactModeBody = "Show more conversation on the screen.", showSuggestions = "Profile suggestions", showSuggestionsBody = "Show people to follow on your profile.",
forYou = "For you", following = "Following", add = "Add", network = "Network", networkTitle = "Find your people", networkBody = "Follow voices from across Los Santos and build a timeline that feels like yours.", networkSearchPlaceholder = "Search names or @usernames", postSearchPlaceholder = "Search posts or #hashtags", searchResults = "Search results", suggestedPeople = "Suggested for you", noPeopleFound = "No people found", noPeopleFoundBody = "Try another name or username.", noSuggestions = "No suggestions yet", noSuggestionsBody = "New Feather accounts will appear here.", all = "All", mentions = "Mentions", media = "Media", trending = "Trending", peopleTab = "People", searchPlaceholder = "Search Feather", search = "Search", cancel = "Cancel", done = "Done",
authEyebrow = "Feather network", authWelcome = "Welcome to Feather", authBody = "One iFruit account. Every conversation, wherever you sign in.",
login = "Log in", register = "Register", loginTitle = "Good to see you again", loginBody = "Log in with your iFruit address to continue to Feather.",
registerTitle = "Create your iFruit account", registerBody = "Choose an address and secure it with a password. Your Feather profile comes next.",
authEyebrow = "Feather network", authWelcome = "Welcome to Feather", authBody = "Your iFruit email is linked automatically. Use your Feather username to continue.",
login = "Log in", register = "Register", loginTitle = "Good to see you again", loginBody = "Use your Feather username. Your iFruit email is linked automatically.",
registerTitle = "Create your Feather profile", registerBody = "Choose a username and optional profile photo. No password is needed.",
email = "iFruit address", emailPlaceholder = "yourname", password = "Password", passwordPlaceholder = "664 characters",
confirmPassword = "Confirm password", confirmPasswordPlaceholder = "Enter it again", showPassword = "Show password", hidePassword = "Hide password",
loginAction = "Continue to Feather", registerAction = "Create account", noAccount = "New to Feather?", haveAccount = "Already registered?",
registerNow = "Create an account", loginNow = "Log in", authTrust = "Your credentials are verified by the server and this phone is linked to your iFruit account.",
loginAction = "Continue to Feather", registerAction = "Create profile", noAccount = "New to Feather?", haveAccount = "Already registered?",
registerNow = "Create a profile", loginNow = "Log in", authTrust = "This Feather session is separate from your other apps and linked to your iFruit email.",
profileStep = "Step 2 of 2", accountConnected = "iFruit account connected",
authErrors = {
invalid_email = "Enter a valid 332 character iFruit address.", invalid_password = "Password must be 664 characters.",
password_mismatch = "The passwords do not match.", invalid_credentials = "The iFruit address or password is incorrect.",
email_taken = "That iFruit address is already registered.", rate_limited = "Too many attempts. Try again in a minute.",
default = "The account request failed. Please try again.",
no_ifruit_account = "Sign in to your iFruit account in Settings first.", invalid_handle = "Use 3-30 letters, numbers or underscores.", invalid_username = "That username does not match your Feather profile.",
profile_not_found = "No Feather profile exists for this iFruit email.", already_registered = "This iFruit email already has a Feather profile.",
handle_taken = "That Feather username is already taken.", invalid_media = "Choose a valid photo from this phone.",
rate_limited = "Too many attempts. Try again in a minute.", default = "Feather could not complete the request. Please try again.",
},
welcome = "Find your voice", welcomeBody = "Join the live conversation in Los Santos. Your Feather profile stays linked to your iFruit account.",
createProfile = "Create Feather profile", profileDetailsHint = "Choose how people will recognize you.", displayName = "Display name", displayNamePlaceholder = "Your name", handle = "Username", handlePlaceholder = "your_username", handleHint = "3-30 letters, numbers or underscores",
@@ -1197,7 +1219,16 @@ Locales["en"] = {
noListingsBody = "Try another search or category.", noDistrict = "No district",
free = "Free", negotiablePrice = "${price} negotiable", money = "${price}",
hoursAgo = "{count}h ago", daysAgo = "{count}d ago", photos = "photos", activeListings = "active listings",
signInTitle = "Sign in to iFruit", signInBody = "Use Settings to sign in before selling, saving or messaging.",
signInTitle = "Sign in to iFruit", signInBody = "Log in to your CityMarkt profile to sell, save or message.",
authEyebrow = "CityMarkt account", authTitle = "Welcome to CityMarkt",
authBody = "Your iFruit email is linked automatically. Use your CityMarkt username to continue.",
login = "Login", register = "Register", authUsername = "Username",
authErrors = {
no_ifruit_account = "Connect an iFruit account in Settings first.",
invalid_username = "Enter the username of your CityMarkt profile.",
profile_not_found = "No CityMarkt profile exists for this iFruit email.",
profile_exists = "A CityMarkt profile already exists. Use Login instead.",
},
noMessages = "No conversations", noMessagesBody = "Messages about offers will appear here.",
myListings = "My listings", favorites = "Favorites", addFavorite = "Add to favorites", removeFavorite = "Remove from favorites", noProfileListings = "Nothing here yet",
createProfile = "Create your CityMarkt profile", editProfile = "Edit profile",
@@ -1254,7 +1285,7 @@ Locales["en"] = {
noPosts = "Nothing here yet", noPostsBody = "Be the first to share something with the city.", noPhoto = "No photo attached",
signInTitle = "Your Local Pages profile", signInBody = "Sign in to iFruit in Settings to publish and save posts.",
authEyebrow = "Local Pages account", authWelcome = "Welcome to Local Pages",
authBody = "Sign in or create an iFruit account to build your local profile.", login = "Sign in", register = "Register",
authBody = "Your iFruit email is linked automatically. Use your Local Pages username to continue.", login = "Sign in", register = "Register",
loginTitle = "Continue with iFruit", loginBody = "Your posts, saved items and profile stay linked to your account.",
registerTitle = "Create an iFruit account", registerBody = "Choose your new iFruit address. Your Local Pages profile comes next.",
authEmail = "iFruit address", authEmailPlaceholder = "your.name", authPassword = "Password",
@@ -1274,7 +1305,13 @@ Locales["en"] = {
cityMarktAppMissing = "Local Pages is not installed", cityMarktInstallHint = "Install Local Pages to share this listing",
cityMarktAccountMissing = "Local Pages profile required", cityMarktAccountHint = "Create a Local Pages profile before sharing",
cityMarktComposeTitle = "Share CityMarkt listing", cityMarktComposeNavTitle = "Share listing", cityMarktComposeHint = "Review the listing and publish it when you are ready.", cityMarktPhotosHint = "The CityMarkt listing photos will be included.",
authErrors = { invalid_email = "Enter a valid 3-32 character iFruit address.", invalid_password = "Use a password between 6 and 64 characters.", invalid_credentials = "The iFruit address or password is incorrect.", email_taken = "This iFruit address is already registered.", password_mismatch = "The passwords do not match.", rate_limited = "Too many attempts. Try again shortly.", default = "The iFruit account request failed." },
authErrors = {
no_ifruit_account = "Sign in to your iFruit account in Settings first.", invalid_username = "Use 3-24 lowercase letters, numbers, dots or underscores.",
profile_not_found = "No Local Pages profile exists for this iFruit email.", profile_exists = "This iFruit email already has a Local Pages profile.",
invalid_profile = "Check your Local Pages username.", invalid_profile_image = "Choose a valid photo from this phone.",
profile_handle_taken = "This Local Pages username is already taken.", rate_limited = "Too many attempts. Try again shortly.",
default = "Local Pages could not complete the request.",
},
errors = { profile_required = "Create your Local Pages profile first.", invalid_profile = "Check your username and bio.", invalid_profile_image = "Choose a valid photo from this phone.", profile_handle_taken = "This username is already taken.", invalid_post = "Add a title and a little more detail.", invalid_images = "Choose valid photos from this phone.", invalid_request = "This action is not valid.", post_not_found = "This post is no longer available.", citymarkt_not_found = "This CityMarkt listing is unavailable.", citymarkt_daily_limit = "You already shared a CityMarkt listing today.", citymarkt_already_shared = "This listing was already shared.", not_authenticated = "Sign in to iFruit first.", rate_limited = "Too many requests. Try again shortly.", request_failed = "The post could not be saved.", default = "Local Pages is temporarily unavailable." },
},
map = {
+45 -12
View File
@@ -82,6 +82,8 @@ local function profile_dto(row)
return {
id = row.id,
username = row.username,
avatarMediaId = row.avatar_media_id and tonumber(row.avatar_media_id) or nil,
avatarUrl = row.avatar_url,
activeGroupId = row.active_group_id,
mapVisible = tonumber(row.map_visible) == 1,
overheadVisible = tonumber(row.overhead_visible) == 1,
@@ -94,9 +96,11 @@ local function require_profile(source)
return nil, error_response
end
local rows = Bridge.Database.Query([[
SELECT `id`, `account_id`, `username`, `active_group_id`, `map_visible`, `overhead_visible`
FROM `sky_phone_crewlink_profiles`
WHERE `account_id` = ?
SELECT p.`id`, p.`account_id`, p.`username`, p.`avatar_media_id`, p.`active_group_id`,
p.`map_visible`, p.`overhead_visible`, avatar.`url` AS `avatar_url`
FROM `sky_phone_crewlink_profiles` p
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
WHERE p.`account_id` = ?
LIMIT 1
]], { account.id })
if not rows[1] then
@@ -131,9 +135,10 @@ end
local function member_dtos(group_id)
local rows = Bridge.Database.Query([[
SELECT p.`id`, p.`account_id`, p.`username`, p.`map_visible`, p.`overhead_visible`,
m.`role`, UNIX_TIMESTAMP(m.`joined_at`) AS `joined_at`
avatar.`url` AS `avatar_url`, m.`role`, UNIX_TIMESTAMP(m.`joined_at`) AS `joined_at`
FROM `sky_phone_crewlink_memberships` m
JOIN `sky_phone_crewlink_profiles` p ON p.`id` = m.`profile_id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
WHERE m.`group_id` = ?
ORDER BY FIELD(m.`role`, 'owner', 'coordinator', 'moderator', 'member', 'guest'), m.`joined_at`
]], { group_id })
@@ -141,8 +146,10 @@ local function member_dtos(group_id)
row.account_id = tonumber(row.account_id)
row.mapVisible = tonumber(row.map_visible) == 1
row.overheadVisible = tonumber(row.overhead_visible) == 1
row.avatarUrl = row.avatar_url
row.map_visible = nil
row.overhead_visible = nil
row.avatar_url = nil
row.joinedAt = (tonumber(row.joined_at) or 0) * 1000
row.joined_at = nil
end
@@ -397,8 +404,11 @@ Bridge.Callbacks.Register("sky_phone:crewlink:bootstrap", function(source)
return error_response
end
local rows = Bridge.Database.Query([[
SELECT `id`, `account_id`, `username`, `active_group_id`, `map_visible`, `overhead_visible`
FROM `sky_phone_crewlink_profiles` WHERE `account_id` = ? LIMIT 1
SELECT p.`id`, p.`account_id`, p.`username`, p.`avatar_media_id`, p.`active_group_id`,
p.`map_visible`, p.`overhead_visible`, avatar.`url` AS `avatar_url`
FROM `sky_phone_crewlink_profiles` p
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
WHERE p.`account_id` = ? LIMIT 1
]], { account.id })
if not rows[1] then
return { success = true, data = { profile = nil, groups = {}, invitations = {} } }
@@ -415,14 +425,22 @@ Bridge.Callbacks.Register("sky_phone:crewlink:create-profile", function(source,
if not account then
return error_response
end
local username = valid_username(data and data.username)
data = type(data) == "table" and data or {}
local username = valid_username(data.username)
if not username then
return { success = false, error = "invalid_username" }
end
local avatar_media_id = tonumber(data.avatarMediaId) or 0
if avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then
return { success = false, error = "invalid_profile_image" }
end
if avatar_media_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, avatar_media_id, "photo") then
return { success = false, error = "invalid_profile_image" }
end
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_crewlink_profiles` (`id`, `account_id`, `username`)
VALUES (?, ?, ?)
]], { new_id(), account.id, username })
INSERT IGNORE INTO `sky_phone_crewlink_profiles` (`id`, `account_id`, `username`, `avatar_media_id`)
VALUES (?, ?, ?, NULLIF(?, 0))
]], { new_id(), account.id, username, avatar_media_id })
if affected_rows(result) ~= 1 then
return { success = false, error = "username_taken" }
end
@@ -442,11 +460,26 @@ Bridge.Callbacks.Register("sky_phone:crewlink:update-profile", function(source,
if not username or type(data.mapVisible) ~= "boolean" or type(data.overheadVisible) ~= "boolean" then
return { success = false, error = "invalid_profile" }
end
local avatar_media_id = profile.avatar_media_id and tonumber(profile.avatar_media_id) or nil
if data.avatarMediaId ~= nil then
local submitted_avatar_id = tonumber(data.avatarMediaId)
if not submitted_avatar_id or submitted_avatar_id < 0
or submitted_avatar_id ~= math.floor(submitted_avatar_id)
then
return { success = false, error = "invalid_profile_image" }
end
if submitted_avatar_id > 0
and not SkyPhoneMedia.ResolveOwnedMedia(source, submitted_avatar_id, "photo")
then
return { success = false, error = "invalid_profile_image" }
end
avatar_media_id = submitted_avatar_id > 0 and submitted_avatar_id or nil
end
local result = Bridge.Database.Query([[
UPDATE IGNORE `sky_phone_crewlink_profiles`
SET `username` = ?, `map_visible` = ?, `overhead_visible` = ?
SET `username` = ?, `map_visible` = ?, `overhead_visible` = ?, `avatar_media_id` = ?
WHERE `id` = ?
]], { username, data.mapVisible and 1 or 0, data.overheadVisible and 1 or 0, profile.id })
]], { username, data.mapVisible and 1 or 0, data.overheadVisible and 1 or 0, avatar_media_id, profile.id })
if affected_rows(result) ~= 1 and username:lower() ~= tostring(profile.username):lower() then
return { success = false, error = "username_taken" }
end
+3
View File
@@ -2098,6 +2098,7 @@ local schema = {
{ 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 = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
{ 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" },
@@ -2111,9 +2112,11 @@ local schema = {
},
indexes = {
{ name = "idx_sky_phone_crewlink_active", columns = "(`active_group_id`)" },
{ name = "idx_sky_phone_crewlink_avatar", columns = "(`avatar_media_id`)" },
},
foreignKeys = {
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
{ column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
+1
View File
@@ -24,6 +24,7 @@ local allowed_device_namespaces = {
notifications = true,
wallpaper = true,
alarms = true,
appAuth = true,
apps = true,
games = true,
widgets = true,
+4 -1
View File
@@ -897,6 +897,7 @@ 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,
`avatar_media_id` BIGINT UNSIGNED 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,
@@ -906,7 +907,9 @@ CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_profiles` (
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
KEY `idx_sky_phone_crewlink_avatar` (`avatar_media_id`),
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_groups` (