FIX - connect marketplace apps to phone media

This commit is contained in:
smx.pusha
2026-08-07 18:00:18 +02:00
parent 580542f4d0
commit ed6d57bfcd
11 changed files with 364 additions and 167 deletions
+27
View File
@@ -31,4 +31,31 @@ describe('message media handoff', () => {
expect(store.request).toMatchObject({ mediaType: 'video', target: '4205550196' })
expect(store.cancel()).toBe('/apps/messages')
})
it('returns multiple photos and the requesting app context', () => {
const store = useMessageMediaStore()
const secondPhoto = { ...photo, id: 18 }
store.begin('citymarkt:sell', 'photo', '/apps/citymarkt?sell=1', 2, {
title: 'Draft listing',
})
expect(store.completeMany([photo, secondPhoto])).toBe('/apps/citymarkt?sell=1')
expect(store.consumeMany<{ title: string }>('citymarkt:sell')).toEqual({
context: { title: 'Draft listing' },
media: [photo, secondPhoto],
})
})
it('preserves the requesting app context when selection is cancelled', () => {
const store = useMessageMediaStore()
store.begin('local-pages:compose', 'photo', '/apps/local-pages?compose=1', 6, {
title: 'Draft post',
})
expect(store.cancel()).toBe('/apps/local-pages?compose=1')
expect(store.consumeMany<{ title: string }>('local-pages:compose')).toEqual({
context: { title: 'Draft post' },
media: [],
})
})
})
+39 -3
View File
@@ -3,13 +3,20 @@ import { defineStore } from 'pinia'
import type { MediaType, PhoneMedia } from '@/types/media'
type MessageMediaRequest = {
context?: unknown
maxSelection: number
mediaType: MediaType
returnPath: string
target: string
}
type MessageMediaResult = MessageMediaRequest & {
media: PhoneMedia
media: PhoneMedia[]
}
export type MediaSelectionResult<T = unknown> = {
context?: T
media: PhoneMedia[]
}
export const useMessageMediaStore = defineStore('message-media', {
@@ -22,17 +29,37 @@ export const useMessageMediaStore = defineStore('message-media', {
target: string,
mediaType: MediaType,
returnPath = '/apps/messages',
maxSelection = 1,
context?: unknown,
): void {
this.request = { mediaType, returnPath, target }
this.request = {
context,
maxSelection: Math.max(1, Math.floor(maxSelection)),
mediaType,
returnPath,
target,
}
this.result = null
},
cancel(): string {
const returnPath = this.request?.returnPath ?? '/apps/messages'
if (this.request) this.result = { ...this.request, media: [] }
this.request = null
return returnPath
},
complete(media: PhoneMedia): string | null {
if (!this.request || this.request.mediaType !== media.mediaType) return null
return this.completeMany([media])
},
completeMany(media: PhoneMedia[]): string | null {
if (
!this.request ||
media.length < 1 ||
media.length > this.request.maxSelection ||
media.some((entry) => entry.mediaType !== this.request?.mediaType)
) {
return null
}
const returnPath = this.request.returnPath
this.result = { ...this.request, media }
this.request = null
@@ -40,9 +67,18 @@ export const useMessageMediaStore = defineStore('message-media', {
},
consume(target: string): PhoneMedia | null {
if (!this.result || this.result.target !== target) return null
const media = this.result.media
const media = this.result.media[0] ?? null
this.result = null
return media
},
consumeMany<T = unknown>(target: string): MediaSelectionResult<T> | null {
if (!this.result || this.result.target !== target) return null
const result = {
context: this.result.context as T | undefined,
media: [...this.result.media],
}
this.result = null
return result
},
},
})
+9 -8
View File
@@ -23,9 +23,9 @@ export type MarketplaceImage = {
export type MarketplaceListingSummary = {
category: MarketplaceCategory
created_at: string
created_at: DatabaseDateValue
district: string | null
expires_at: string
expires_at: DatabaseDateValue
id: string
image: string | null
is_favorite: boolean | number
@@ -35,7 +35,7 @@ export type MarketplaceListingSummary = {
seller_name: string
status: MarketplaceStatus
title: string
updated_at: string
updated_at: DatabaseDateValue
}
export type MarketplaceListing = MarketplaceListingSummary & {
@@ -46,7 +46,7 @@ export type MarketplaceListing = MarketplaceListingSummary & {
reserved_account_id: number | null
revision: number
seller_active: number
seller_since: string
seller_since: DatabaseDateValue
show_phone: boolean | number
}
@@ -75,12 +75,12 @@ export type MarketplaceInquirySummary = {
status: MarketplaceStatus
title: string
unread: number
updated_at: string
updated_at: DatabaseDateValue
}
export type MarketplaceMessage = {
body: string
created_at: string
created_at: DatabaseDateValue
id: number
read_at: string | null
sender_account_id: number
@@ -88,13 +88,13 @@ export type MarketplaceMessage = {
export type MarketplaceOffer = {
amount: number | string
created_at: string
created_at: DatabaseDateValue
id: number
proposer_account_id: number
read_at: string | null
response_read_at: string | null
status: MarketplaceOfferStatus
updated_at: string
updated_at: DatabaseDateValue
}
export type MarketplaceInquiry = {
@@ -124,3 +124,4 @@ export type MarketplaceChat = {
}
export type MarketplaceCounts = { active: number; unread: number }
import type { DatabaseDateValue } from '@/utils/date'
+28 -2
View File
@@ -7,6 +7,7 @@ import {
kSegmentedButton,
} from 'konsta/vue'
import {
ArrowLeft,
Images,
RefreshCw,
RotateCcwSquare,
@@ -38,7 +39,7 @@ const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const requestedMessageMedia = computed<MediaType | null>(() => {
const value = route.query.messageAttachment
const value = route.query.mediaAttachment ?? route.query.messageAttachment
return value === 'photo' || value === 'video' ? value : null
})
const mode = ref<MediaType>(requestedMessageMedia.value ?? 'photo')
@@ -205,6 +206,10 @@ function capture(): void {
void requestPhoto()
}
function cancelMediaSelection(): void {
void router.replace(messageMedia.cancel())
}
function setMode(nextMode: MediaType): void {
if (recording.value || savingVideo.value) return
mode.value = nextMode
@@ -413,7 +418,17 @@ onBeforeUnmount(() => {
</div>
<header class="camera-topbar">
<button
v-if="requestedMessageMedia"
class="camera-picker-back"
type="button"
:aria-label="phone.t('Common.back')"
@click="cancelMediaSelection"
>
<ArrowLeft :size="20" />
</button>
<k-fab
v-if="!requestedMessageMedia"
component="button"
type="button"
class="camera-control"
@@ -489,7 +504,7 @@ onBeforeUnmount(() => {
router.push({
path: '/apps/photos',
query: requestedMessageMedia
? { messageAttachment: requestedMessageMedia }
? { mediaAttachment: requestedMessageMedia }
: undefined,
})
"
@@ -671,6 +686,17 @@ onBeforeUnmount(() => {
.camera-control {
--color-primary: transparent;
}
.camera-picker-back {
width: 44px;
height: 44px;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: #1c1c1ecc;
color: #fff;
backdrop-filter: blur(16px);
}
.camera-control svg {
width: 21px;
height: 21px;
+98 -56
View File
@@ -29,17 +29,19 @@ import {
Wrench,
X,
} from 'lucide-vue-next'
import { kButton } from 'konsta/vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
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 { useAccountStore } from '@/stores/account'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useMediaStore } from '@/stores/media'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePagesStore } from '@/stores/pages'
import { usePhoneStore } from '@/stores/phone'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
import type {
MarketplaceCategory,
MarketplaceChat,
@@ -55,14 +57,32 @@ import type {
type Tab = 'discover' | 'search' | 'sell' | 'inbox' | 'profile'
type Screen = 'main' | 'detail' | 'sell' | 'chat' | 'report'
type ChatTimelineItem =
| { createdAt: string; key: string; kind: 'message'; value: MarketplaceMessage }
| { createdAt: string; isCounter: boolean; key: string; kind: 'offer'; value: MarketplaceOffer }
| { createdAt: DatabaseDateValue; key: string; kind: 'message'; value: MarketplaceMessage }
| { createdAt: DatabaseDateValue; isCounter: boolean; key: string; kind: 'offer'; value: MarketplaceOffer }
type SelectedPhoto = { background: string; id: string }
type SellDraft = {
category: MarketplaceCategory
condition: MarketplaceCondition
description: string
district: string
price: string
priceType: MarketplacePriceType
showPhone: boolean
title: string
}
type MediaContext = {
draft: SellDraft
editing: { id: string; revision: number } | null
photos: SelectedPhoto[]
sellStep: number
}
const phone = usePhoneStore()
const route = useRoute()
const router = useRouter()
const account = useAccountStore()
const marketplace = useMarketplaceStore()
const media = useMediaStore()
const messageMedia = useMessageMediaStore()
const pages = usePagesStore()
const tab = ref<Tab>('discover')
const screen = ref<Screen>('main')
@@ -82,8 +102,7 @@ const feedback = ref('')
const sellStep = ref(1)
const submitting = ref(false)
const selectedPhotoIds = ref<string[]>([])
const photoSource = ref<'camera' | 'gallery' | null>(null)
const cameraFlash = ref(false)
const pickedPhotos = ref<SelectedPhoto[]>([])
const reportReason = ref('spam')
const reportDetails = ref('')
const editing = ref<{ id: string; revision: number } | null>(null)
@@ -91,7 +110,7 @@ const listingTextLimits = {
description: { maximum: 2000, minimum: 20 },
title: { maximum: 70, minimum: 5 },
} as const
const draft = ref({
const draft = ref<SellDraft>({
category: 'vehicles' as MarketplaceCategory,
condition: 'used' as MarketplaceCondition,
description: '',
@@ -175,9 +194,9 @@ const displayItems = computed(() => {
})
const draftImages = computed(() =>
selectedPhotoIds.value.flatMap((id, index) => {
const photo = media.photos.find((item) => item.id === id)
const photo = pickedPhotos.value.find((item) => item.id === id)
return photo
? [{ gradient: photo.gradient, media_id: photo.id, sort_order: index + 1 }]
? [{ gradient: photo.background, media_id: photo.id, sort_order: index + 1 }]
: []
}),
)
@@ -198,8 +217,8 @@ const chatTimeline = computed<ChatTimelineItem[]>(() => {
}))
return [...messages, ...offers].sort(
(left, right) =>
new Date(left.createdAt.replace(' ', 'T')).getTime() -
new Date(right.createdAt.replace(' ', 'T')).getTime(),
parseDatabaseDate(left.createdAt).getTime() -
parseDatabaseDate(right.createdAt).getTime(),
)
})
const canMakeOffer = computed(() => {
@@ -243,16 +262,16 @@ function formatPrice(item: { price: number | string | null; price_type: Marketpl
: phone.t('Apps.citymarkt.money', { price: formatted })
}
function relativeDate(value: string): string {
const timestamp = new Date(value.replace(' ', 'T')).getTime()
function relativeDate(value: DatabaseDateValue): string {
const timestamp = parseDatabaseDate(value).getTime()
const hours = Math.max(1, Math.floor((Date.now() - timestamp) / 3_600_000))
if (hours < 24) return phone.t('Apps.citymarkt.hoursAgo', { count: String(hours) })
return phone.t('Apps.citymarkt.daysAgo', { count: String(Math.floor(hours / 24)) })
}
function messageTime(value: string): string {
function messageTime(value: DatabaseDateValue): string {
return new Intl.DateTimeFormat(phone.lang, { hour: '2-digit', minute: '2-digit' }).format(
new Date(value.replace(' ', 'T')),
parseDatabaseDate(value),
)
}
@@ -351,27 +370,38 @@ async function toggleFavorite(): Promise<void> {
function togglePhoto(id: string): void {
const index = selectedPhotoIds.value.indexOf(id)
if (index >= 0) selectedPhotoIds.value.splice(index, 1)
else if (selectedPhotoIds.value.length < 6) selectedPhotoIds.value.push(id)
else setFeedback('Apps.citymarkt.photoLimit')
if (index >= 0) {
selectedPhotoIds.value.splice(index, 1)
pickedPhotos.value = pickedPhotos.value.filter((photo) => photo.id !== id)
}
}
function captureMarketplacePhoto(): void {
if (selectedPhotoIds.value.length >= 6) {
function openMediaApp(app: 'camera' | 'photos'): void {
const remaining = 6 - selectedPhotoIds.value.length
if (remaining < 1) {
setFeedback('Apps.citymarkt.photoLimit')
return
}
cameraFlash.value = true
const photo = media.capture()
selectedPhotoIds.value.push(photo.id)
window.setTimeout(() => (cameraFlash.value = false), 120)
messageMedia.begin(
'citymarkt:sell',
'photo',
'/apps/citymarkt?sell=1',
app === 'photos' ? remaining : 1,
{
draft: { ...draft.value },
editing: editing.value ? { ...editing.value } : null,
photos: [...pickedPhotos.value],
sellStep: sellStep.value,
} satisfies MediaContext,
)
void router.push({ path: `/apps/${app}`, query: { mediaAttachment: 'photo' } })
}
function resetSell(): void {
editing.value = null
sellStep.value = 1
selectedPhotoIds.value = []
photoSource.value = null
pickedPhotos.value = []
draft.value = {
category: 'vehicles',
condition: 'used',
@@ -388,6 +418,10 @@ function editListing(): void {
if (!selectedListing.value) return
editing.value = { id: selectedListing.value.id, revision: selectedListing.value.revision }
selectedPhotoIds.value = selectedListing.value.images.map((image) => image.media_id)
pickedPhotos.value = selectedListing.value.images.map((image) => ({
background: image.gradient,
id: image.media_id,
}))
draft.value = {
category: selectedListing.value.category,
condition: selectedListing.value.item_condition,
@@ -565,6 +599,26 @@ async function blockSeller(): Promise<void> {
}
onMounted(async () => {
const selection = messageMedia.consumeMany<MediaContext>('citymarkt:sell')
if (selection) {
if (selection.context) {
draft.value = selection.context.draft
editing.value = selection.context.editing
pickedPhotos.value = selection.context.photos
sellStep.value = selection.context.sellStep
selectedPhotoIds.value = selection.context.photos.map((photo) => photo.id)
}
for (const media of selection.media) {
const id = String(media.id)
if (selectedPhotoIds.value.includes(id) || selectedPhotoIds.value.length >= 6) continue
selectedPhotoIds.value.push(id)
pickedPhotos.value.push({ background: `url(${JSON.stringify(media.url)})`, id })
}
}
if (route.query.sell === '1') {
tab.value = 'sell'
screen.value = 'sell'
}
await loadFeed()
if (isAuthenticated.value) await marketplace.loadCounts()
if (typeof route.query.listingId === 'string') {
@@ -583,7 +637,10 @@ onMounted(async () => {
</span>
<h1>{{ phone.t(tab === 'discover' ? 'Apps.citymarkt.name' : `Apps.citymarkt.tabs.${tab}`) }}</h1>
</div>
<button
<k-button
component="button"
clear
rounded
v-if="isAuthenticated"
class="citymarkt__round"
type="button"
@@ -592,7 +649,7 @@ onMounted(async () => {
>
<Bell :size="18" />
<i v-if="marketplace.counts.unread" />
</button>
</k-button>
</header>
<section v-if="screen === 'main'" class="citymarkt__content">
@@ -696,7 +753,13 @@ onMounted(async () => {
</section>
<section v-else-if="screen === 'detail' && selectedListing" class="citymarkt__detail">
<div class="citymarkt__top-actions"><button @click="screen = 'main'"><ArrowLeft :size="19" /></button><div><button v-if="!selectedListing.is_owner" @click="toggleFavorite"><Heart :size="19" :fill="selectedListing.is_favorite ? 'currentColor' : 'none'" /></button><button v-if="!selectedListing.is_owner" @click="screen = 'report'"><MoreHorizontal :size="20" /></button></div></div>
<div class="citymarkt__top-actions">
<k-button component="button" clear rounded @click="screen = 'main'"><ArrowLeft :size="19" /></k-button>
<div>
<k-button v-if="!selectedListing.is_owner" component="button" clear rounded @click="toggleFavorite"><Heart :size="19" :fill="selectedListing.is_favorite ? 'currentColor' : 'none'" /></k-button>
<k-button v-if="!selectedListing.is_owner" component="button" clear rounded @click="screen = 'report'"><MoreHorizontal :size="20" /></k-button>
</div>
</div>
<CityMarktGallery
class="citymarkt__hero"
:images="selectedListing.images"
@@ -730,7 +793,7 @@ onMounted(async () => {
</section>
<section v-else-if="screen === 'sell'" class="citymarkt__sell">
<header><button @click="resetSell(); screen = 'main'; tab = 'discover'"><X :size="20" /></button><div><strong>{{ phone.t(editing ? 'Apps.citymarkt.editListing' : 'Apps.citymarkt.createListing') }}</strong><small>{{ phone.t('Apps.citymarkt.step', { current: String(sellStep), total: '4' }) }}</small></div><button :disabled="!canContinueSell || submitting" @click="sellStep < 4 ? sellStep++ : publish()">{{ sellStep < 4 ? phone.t('Apps.citymarkt.next') : phone.t(editing ? 'Apps.citymarkt.save' : 'Apps.citymarkt.publish') }}</button></header>
<header><k-button component="button" clear rounded @click="resetSell(); screen = 'main'; tab = 'discover'"><X :size="20" /></k-button><div><strong>{{ phone.t(editing ? 'Apps.citymarkt.editListing' : 'Apps.citymarkt.createListing') }}</strong><small>{{ phone.t('Apps.citymarkt.step', { current: String(sellStep), total: '4' }) }}</small></div><k-button component="button" clear rounded :disabled="!canContinueSell || submitting" @click="sellStep < 4 ? sellStep++ : publish()">{{ sellStep < 4 ? phone.t('Apps.citymarkt.next') : phone.t(editing ? 'Apps.citymarkt.save' : 'Apps.citymarkt.publish') }}</k-button></header>
<div class="citymarkt__progress"><i :style="{ width: `${sellStep * 25}%` }" /></div>
<div class="citymarkt__sell-body">
<template v-if="sellStep === 1">
@@ -738,12 +801,12 @@ onMounted(async () => {
<h2>{{ phone.t('Apps.citymarkt.addPhotos') }}</h2>
<p>{{ phone.t('Apps.citymarkt.addPhotosBody') }}</p>
<div class="citymarkt__photo-actions">
<button type="button" @click="photoSource = 'gallery'">
<button type="button" @click="openMediaApp('photos')">
<span><Images :size="20" /></span>
<strong>{{ phone.t('Apps.citymarkt.chooseGallery') }}</strong>
<small>{{ phone.t('Apps.citymarkt.chooseGalleryBody') }}</small>
</button>
<button type="button" @click="photoSource = 'camera'">
<button type="button" @click="openMediaApp('camera')">
<span><Camera :size="20" /></span>
<strong>{{ phone.t('Apps.citymarkt.takePhotos') }}</strong>
<small>{{ phone.t('Apps.citymarkt.takePhotosBody') }}</small>
@@ -798,31 +861,10 @@ onMounted(async () => {
<template v-else><h2>{{ phone.t('Apps.citymarkt.preview') }}</h2><CityMarktGallery class="citymarkt__preview-image" :images="draftImages" :empty-title="phone.t('Apps.citymarkt.noPhoto')" :empty-body="phone.t('Apps.citymarkt.noPhotoBody')" :previous-label="phone.t('Apps.citymarkt.previousPhoto')" :next-label="phone.t('Apps.citymarkt.nextPhoto')" :photo-label="phone.t('Apps.citymarkt.photo')" /><strong class="citymarkt__preview-price">{{ formatPrice({ price: draft.price, price_type: draft.priceType }) }}</strong><h3>{{ draft.title }}</h3><p>{{ draft.description }}</p><small><MapPin :size="13" /> {{ label('districts', draft.district) }}</small></template>
</div>
<button v-if="sellStep > 1" class="citymarkt__previous" @click="sellStep--">{{ phone.t('Apps.citymarkt.previous') }}</button>
<div v-if="photoSource" class="citymarkt__photo-source">
<header>
<div>
<small>{{ phone.t('Apps.citymarkt.addPhotos') }}</small>
<strong>{{ phone.t(photoSource === 'gallery' ? 'Apps.citymarkt.gallery' : 'Apps.citymarkt.camera') }}</strong>
</div>
<span>{{ selectedPhotoIds.length }} / 6</span>
<button type="button" :aria-label="phone.t('Common.close')" @click="photoSource = null"><X :size="18" /></button>
</header>
<div v-if="photoSource === 'gallery'" class="citymarkt__photo-picker">
<button v-for="photo in media.photos" :key="photo.id" type="button" :class="{ active: selectedPhotoIds.includes(photo.id) }" :style="{ background: photo.gradient }" @click="togglePhoto(photo.id)"><i>{{ selectedPhotoIds.indexOf(photo.id) + 1 }}</i></button>
</div>
<div v-else class="citymarkt__capture">
<div class="citymarkt__viewfinder" :style="{ background: media.photos[0]?.gradient }">
<i v-for="corner in ['tl', 'tr', 'bl', 'br']" :key="corner" :class="`corner-${corner}`" />
<span class="citymarkt__camera-flash" :class="{ active: cameraFlash }" />
</div>
<p>{{ phone.t('Apps.citymarkt.cameraHint') }}</p>
<button class="citymarkt__shutter" type="button" :aria-label="phone.t('Apps.citymarkt.takePhoto')" @click="captureMarketplacePhoto"><Camera :size="23" /></button>
</div>
</div>
</section>
<section v-else-if="screen === 'chat' && selectedChat" class="citymarkt__chat">
<header><button @click="screen = 'main'; tab = 'inbox'"><ArrowLeft :size="19" /></button><div><strong>{{ selectedChat.inquiry.seller_account_id === selectedChat.accountId ? selectedChat.inquiry.buyer_name : selectedChat.inquiry.seller_name }}</strong><small>{{ selectedChat.inquiry.title }}</small></div></header>
<header><k-button component="button" clear rounded @click="screen = 'main'; tab = 'inbox'"><ArrowLeft :size="19" /></k-button><div><strong>{{ selectedChat.inquiry.seller_account_id === selectedChat.accountId ? selectedChat.inquiry.buyer_name : selectedChat.inquiry.seller_name }}</strong><small>{{ selectedChat.inquiry.title }}</small></div></header>
<div class="citymarkt__chat-listing"><div><strong>{{ formatPrice(selectedChat.inquiry) }}</strong><span>{{ selectedChat.inquiry.title }}</span></div><i>{{ label('status', selectedChat.inquiry.status) }}</i></div>
<div class="citymarkt__messages">
<template v-for="item in chatTimeline" :key="item.key">
@@ -851,7 +893,7 @@ onMounted(async () => {
<small>{{ phone.t('Apps.citymarkt.offerFor') }}</small>
<strong>{{ selectedChat.inquiry.title }}</strong>
</div>
<button type="button" @click="offerPanelOpen = false"><X :size="16" /></button>
<k-button component="button" clear rounded type="button" @click="offerPanelOpen = false"><X :size="16" /></k-button>
</header>
<label>
{{ phone.t('Apps.citymarkt.offerAmount') }}
@@ -871,7 +913,7 @@ onMounted(async () => {
</section>
<section v-else-if="screen === 'report' && selectedListing" class="citymarkt__report">
<header><button @click="screen = 'detail'"><ArrowLeft :size="19" /></button><strong>{{ phone.t('Apps.citymarkt.reportListing') }}</strong></header>
<header><k-button component="button" clear rounded @click="screen = 'detail'"><ArrowLeft :size="19" /></k-button><strong>{{ phone.t('Apps.citymarkt.reportListing') }}</strong></header>
<div><h2>{{ phone.t('Apps.citymarkt.reportWhy') }}</h2><CityMarktSelect class="citymarkt__form-select" :model-value="reportReason" :options="reportReasonOptions" @change="selectReportReason" /><textarea v-model="reportDetails" maxlength="500" :placeholder="phone.t('Apps.citymarkt.reportDetails')" /><button @click="submitReport">{{ phone.t('Apps.citymarkt.sendReport') }}</button><button class="secondary" @click="blockSeller">{{ phone.t('Apps.citymarkt.blockSeller') }}</button></div>
</section>
+61 -1
View File
@@ -41,9 +41,13 @@ const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const requestedMessageMedia = computed<GalleryFilter | null>(() => {
const value = route.query.messageAttachment
const value = route.query.mediaAttachment ?? route.query.messageAttachment
return value === 'photo' || value === 'video' ? value : null
})
const multipleSelection = computed(
() => requestedMessageMedia.value !== null && (messageMedia.request?.maxSelection ?? 1) > 1,
)
const selectedMediaIds = ref<number[]>([])
const media = ref<PhoneMedia[]>([])
const filter = ref<GalleryFilter>(requestedMessageMedia.value ?? 'all')
const loading = ref(true)
@@ -203,6 +207,14 @@ function observeMore(): void {
function openMedia(entry: PhoneMedia): void {
if (requestedMessageMedia.value) {
if (multipleSelection.value) {
const index = selectedMediaIds.value.indexOf(entry.id)
if (index >= 0) selectedMediaIds.value.splice(index, 1)
else if (selectedMediaIds.value.length < (messageMedia.request?.maxSelection ?? 1)) {
selectedMediaIds.value.push(entry.id)
}
return
}
const returnPath = messageMedia.complete(entry)
if (returnPath) void router.replace(returnPath)
return
@@ -214,6 +226,15 @@ function openMedia(entry: PhoneMedia): void {
imagePan.value = { x: 0, y: 0 }
}
function completeMultipleSelection(): void {
const selectedMedia = selectedMediaIds.value.flatMap((id) => {
const entry = media.value.find((item) => item.id === id)
return entry ? [entry] : []
})
const returnPath = messageMedia.completeMany(selectedMedia)
if (returnPath) void router.replace(returnPath)
}
function cancelMessageSelection(): void {
void router.replace(messageMedia.cancel())
}
@@ -352,6 +373,15 @@ onBeforeUnmount(() => {
@click="cancelMessageSelection"
/>
</template>
<template v-if="multipleSelection" #right>
<k-link
component="button"
:disabled="!selectedMediaIds.length"
@click="completeMultipleSelection"
>
{{ phone.t('Common.done') }}
</k-link>
</template>
</k-navbar>
<div class="gallery-content">
@@ -375,6 +405,7 @@ onBeforeUnmount(() => {
v-for="entry in media"
:key="entry.id"
class="gallery-tile"
:class="{ 'gallery-tile--selected': selectedMediaIds.includes(entry.id) }"
type="button"
:aria-label="
phone.t(
@@ -401,6 +432,12 @@ onBeforeUnmount(() => {
<span v-if="entry.mediaType === 'video'" class="gallery-video-badge">
<Play :size="16" fill="currentColor" />
</span>
<span
v-if="multipleSelection && selectedMediaIds.includes(entry.id)"
class="gallery-selection-badge"
>
{{ selectedMediaIds.indexOf(entry.id) + 1 }}
</span>
</button>
<span
v-if="hasMore"
@@ -594,6 +631,29 @@ onBeforeUnmount(() => {
border: 0;
background: #d1d1d6;
}
.gallery-tile--selected::after {
content: '';
position: absolute;
inset: 0;
border: 3px solid #0a84ff;
pointer-events: none;
}
.gallery-selection-badge {
position: absolute;
top: 7px;
right: 7px;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 2px solid #fff;
border-radius: 50%;
background: #0a84ff;
color: #fff;
font-size: 12px;
font-weight: 700;
box-shadow: 0 2px 6px #0006;
}
.gallery-grid--fill .gallery-tile {
height: 100%;
}
+59 -40
View File
@@ -18,24 +18,36 @@ import {
UserRound,
X,
} from 'lucide-vue-next'
import { kButton } from 'konsta/vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
import { useAccountStore } from '@/stores/account'
import { useMediaStore } from '@/stores/media'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePagesStore } from '@/stores/pages'
import { usePhoneStore } from '@/stores/phone'
import type { PagesCategory, PagesPost } from '@/types/pages'
type SelectedPhoto = { background: string; id: string }
type ComposeDraft = {
body: string
category: Exclude<PagesCategory, 'citymarkt'>
district: string
images: string[]
title: string
}
type MediaContext = { draft: ComposeDraft; photos: SelectedPhoto[] }
type Screen = 'main' | 'detail' | 'compose'
type Tab = 'feed' | 'create' | 'profile'
const phone = usePhoneStore()
const account = useAccountStore()
const media = useMediaStore()
const messageMedia = useMessageMediaStore()
const pages = usePagesStore()
const route = useRoute()
const router = useRouter()
const screen = ref<Screen>('main')
const tab = ref<Tab>('feed')
@@ -46,9 +58,8 @@ const search = ref('')
const category = ref<string>('all')
const feedback = ref('')
const reactionPending = ref(false)
const photoSource = ref<'camera' | 'gallery' | null>(null)
const cameraFlash = ref(false)
const draft = ref({
const pickedPhotos = ref<SelectedPhoto[]>([])
const draft = ref<ComposeDraft>({
body: '',
category: 'recommendation' as Exclude<PagesCategory, 'citymarkt'>,
district: 'los_santos',
@@ -76,10 +87,10 @@ const displayedPosts = computed(() => tab.value === 'profile'
: pages.items)
const isAuthenticated = computed(() => Boolean(account.email))
const selectedPhotos = computed(() => draft.value.images
.map((id) => media.photos.find((photo) => photo.id === id))
.map((id) => pickedPhotos.value.find((photo) => photo.id === id))
.filter((photo) => photo !== undefined))
const selectedImages = computed(() => selectedPhotos.value.map((photo, index) => ({
gradient: photo.gradient,
gradient: photo.background,
media_id: photo.id,
sort_order: index + 1,
})))
@@ -134,19 +145,29 @@ async function openPost(post: PagesPost): Promise<void> {
function togglePhoto(id: string): void {
const index = draft.value.images.indexOf(id)
if (index >= 0) draft.value.images.splice(index, 1)
else if (draft.value.images.length < 6) draft.value.images.push(id)
else showFeedback('Apps.localPages.photoLimit')
if (index >= 0) {
draft.value.images.splice(index, 1)
pickedPhotos.value = pickedPhotos.value.filter((photo) => photo.id !== id)
}
}
function capturePhoto(): void {
if (draft.value.images.length >= 6) {
function openMediaApp(app: 'camera' | 'photos'): void {
const remaining = 6 - draft.value.images.length
if (remaining < 1) {
showFeedback('Apps.localPages.photoLimit')
return
}
cameraFlash.value = true
draft.value.images.push(media.capture().id)
window.setTimeout(() => (cameraFlash.value = false), 120)
messageMedia.begin(
'local-pages:compose',
'photo',
'/apps/local-pages?compose=1',
app === 'photos' ? remaining : 1,
{
draft: { ...draft.value, images: [...draft.value.images] },
photos: [...pickedPhotos.value],
} satisfies MediaContext,
)
void router.push({ path: `/apps/${app}`, query: { mediaAttachment: 'photo' } })
}
async function publish(): Promise<void> {
@@ -166,7 +187,7 @@ async function publish(): Promise<void> {
return
}
draft.value = { body: '', category: 'recommendation', district: 'los_santos', images: [], title: '' }
photoSource.value = null
pickedPhotos.value = []
tab.value = 'feed'
screen.value = 'main'
showFeedback('Apps.localPages.published')
@@ -225,7 +246,23 @@ function openCityMarktListing(): void {
})
}
onMounted(() => void loadFeed())
onMounted(() => {
const selection = messageMedia.consumeMany<MediaContext>('local-pages:compose')
if (selection) {
if (selection.context) {
draft.value = selection.context.draft
pickedPhotos.value = selection.context.photos
}
for (const media of selection.media) {
const id = String(media.id)
if (draft.value.images.includes(id) || draft.value.images.length >= 6) continue
draft.value.images.push(id)
pickedPhotos.value.push({ background: `url(${JSON.stringify(media.url)})`, id })
}
}
if (route.query.compose === '1') screen.value = 'compose'
void loadFeed()
})
</script>
<template>
@@ -282,7 +319,7 @@ onMounted(() => void loadFeed())
</template>
<section v-else-if="screen === 'detail' && selected" class="pages__detail">
<header><button @click="screen = 'main'"><ArrowLeft :size="20" /></button><strong>{{ phone.t('Apps.localPages.post') }}</strong><button v-if="selected.is_owner" class="danger" @click="removePost"><Trash2 :size="18" /></button><button v-else @click="react('save')"><Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" /></button></header>
<header><k-button component="button" clear rounded @click="screen = 'main'"><ArrowLeft :size="20" /></k-button><strong>{{ phone.t('Apps.localPages.post') }}</strong><k-button v-if="selected.is_owner" component="button" clear rounded class="danger" @click="removePost"><Trash2 :size="18" /></k-button><k-button v-else component="button" clear rounded @click="react('save')"><Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" /></k-button></header>
<div class="pages__detail-scroll">
<div v-if="selected.images.length" class="pages__gallery" :style="{ background: selected.images[galleryIndex]?.gradient }"><button v-if="selected.images.length > 1" @click="moveGallery(-1)"><ChevronLeft /></button><button v-if="selected.images.length > 1" @click="moveGallery(1)"><ChevronRight /></button><span>{{ galleryIndex + 1 }} / {{ selected.images.length }}</span></div>
<article><div class="pages__author"><span>{{ selected.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ selected.author_name }}</strong><small>{{ relativeDate(selected.created_at) }}</small></div><i>{{ label('categories', selected.category) }}</i></div><h1>{{ selected.title }}</h1><p>{{ selected.body }}</p><div class="pages__location"><MapPin :size="17" /><div><small>{{ phone.t('Apps.localPages.location') }}</small><strong>{{ selected.district ? phone.t(`Apps.citymarkt.districts.${selected.district}`) : phone.t('Apps.localPages.allLosSantos') }}</strong></div></div><button v-if="selected.source_type === 'citymarkt'" class="pages__market-link" @click="openCityMarktListing"><Store :size="18" /><span><small>{{ phone.t('Apps.localPages.sharedFrom') }}</small><strong>{{ phone.t('Apps.localPages.openCityMarkt') }}</strong></span><b v-if="selected.citymarkt_price">${{ Number(selected.citymarkt_price).toLocaleString() }}</b></button></article>
@@ -291,7 +328,7 @@ onMounted(() => void loadFeed())
</section>
<section v-else class="pages__compose">
<header><button @click="screen = 'main'"><X :size="20" /></button><div><small>{{ phone.t('Apps.localPages.newPost') }}</small><strong>{{ phone.t('Apps.localPages.shareWithCity') }}</strong></div><button :disabled="!canPublish" @click="publish"><Send :size="16" />{{ phone.t('Apps.localPages.publish') }}</button></header>
<header><k-button component="button" clear rounded @click="screen = 'main'"><X :size="20" /></k-button><div><small>{{ phone.t('Apps.localPages.newPost') }}</small><strong>{{ phone.t('Apps.localPages.shareWithCity') }}</strong></div><k-button component="button" tonal rounded :disabled="!canPublish" @click="publish"><Send :size="16" />{{ phone.t('Apps.localPages.publish') }}</k-button></header>
<div class="pages__compose-scroll">
<label>{{ phone.t('Apps.localPages.title') }} <span :class="{ valid: draft.title.trim().length >= 5 }">{{ draft.title.trim().length }}/80 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '5' }) }}</span><input v-model="draft.title" maxlength="80" :placeholder="phone.t('Apps.localPages.titlePlaceholder')" /></label>
<label>{{ phone.t('Apps.localPages.body') }} <span :class="{ valid: draft.body.trim().length >= 10 }">{{ draft.body.trim().length }}/1500 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '10' }) }}</span><textarea v-model="draft.body" maxlength="1500" :placeholder="phone.t('Apps.localPages.bodyPlaceholder')" /></label>
@@ -301,12 +338,12 @@ onMounted(() => void loadFeed())
<h2>{{ phone.t('Apps.citymarkt.addPhotos') }}</h2>
<p>{{ phone.t('Apps.citymarkt.addPhotosBody') }}</p>
<div class="pages__photo-actions">
<button type="button" @click="photoSource = 'gallery'">
<button type="button" @click="openMediaApp('photos')">
<span><Images :size="20" /></span>
<strong>{{ phone.t('Apps.citymarkt.chooseGallery') }}</strong>
<small>{{ phone.t('Apps.citymarkt.chooseGalleryBody') }}</small>
</button>
<button type="button" @click="photoSource = 'camera'">
<button type="button" @click="openMediaApp('camera')">
<span><Camera :size="20" /></span>
<strong>{{ phone.t('Apps.citymarkt.takePhotos') }}</strong>
<small>{{ phone.t('Apps.citymarkt.takePhotosBody') }}</small>
@@ -330,24 +367,6 @@ onMounted(() => void loadFeed())
</div>
</section>
</div>
<div v-if="photoSource" class="pages__photo-source">
<header>
<div><small>{{ phone.t('Apps.citymarkt.addPhotos') }}</small><strong>{{ phone.t(photoSource === 'gallery' ? 'Apps.citymarkt.gallery' : 'Apps.citymarkt.camera') }}</strong></div>
<span>{{ draft.images.length }} / 6</span>
<button type="button" :aria-label="phone.t('Common.close')" @click="photoSource = null"><X :size="18" /></button>
</header>
<div v-if="photoSource === 'gallery'" class="pages__photo-picker">
<button v-for="photo in media.photos" :key="photo.id" type="button" :class="{ active: draft.images.includes(photo.id) }" :style="{ background: photo.gradient }" @click="togglePhoto(photo.id)"><i v-if="draft.images.includes(photo.id)">{{ draft.images.indexOf(photo.id) + 1 }}</i></button>
</div>
<div v-else class="pages__capture">
<div class="pages__viewfinder" :style="{ background: media.photos[0]?.gradient }">
<i v-for="corner in ['tl', 'tr', 'bl', 'br']" :key="corner" :class="`corner-${corner}`" />
<span class="pages__camera-flash" :class="{ active: cameraFlash }" />
</div>
<p>{{ phone.t('Apps.citymarkt.cameraHint') }}</p>
<button class="pages__shutter" type="button" :aria-label="phone.t('Apps.citymarkt.takePhoto')" @click="capturePhoto"><Camera :size="23" /></button>
</div>
</div>
</section>
<Transition name="toast"><div v-if="feedback" class="pages__toast">{{ feedback }}</div></Transition>
</main>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-Jjqh_BPD.js"></script>
<script type="module" crossorigin src="./assets/sky-index-DdYU6-mm.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-Bhv3YOTT.css">
</head>
<body>
+10 -2
View File
@@ -436,7 +436,7 @@ local schema = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "listing_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "media_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "gradient", type = "VARCHAR(160) NOT NULL" },
{ name = "gradient", type = "VARCHAR(2200) NOT NULL" },
{ name = "sort_order", type = "TINYINT UNSIGNED NOT NULL" },
},
primaryKey = "id",
@@ -619,7 +619,7 @@ local schema = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "media_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "gradient", type = "VARCHAR(160) NOT NULL" },
{ name = "gradient", type = "VARCHAR(2200) NOT NULL" },
{ name = "sort_order", type = "TINYINT UNSIGNED NOT NULL" },
},
primaryKey = "id",
@@ -841,6 +841,14 @@ Bridge.Database.Query([[
ALTER TABLE `sky_phone_darkchat_messages`
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'system') NOT NULL DEFAULT 'text'
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_marketplace_images`
MODIFY COLUMN `gradient` VARCHAR(2200) NOT NULL
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_pages_images`
MODIFY COLUMN `gradient` VARCHAR(2200) NOT NULL
]], {})
Bridge.Database.EnsureIndex("sky_phone_devices", "uniq_sky_phone_devices_sim", "(`sim_id`)", { unique = true })
Bridge.Database.Query("UPDATE `sky_phone_contacts` SET `contact_id` = `id` WHERE `contact_id` IS NULL", {})
Bridge.Database.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_account_contact", "(`account_id`, `contact_id`)", { unique = true })
+18 -28
View File
@@ -1,7 +1,6 @@
Bridge.Database.AfterMigration("sky_phone", function()
local categories = {}
local districts = {}
local photo_gradients = {}
local item_conditions = { new = true, very_good = true, used = true, defective = true }
local price_types = { fixed = true, negotiable = true, free = true }
local report_reasons = { prohibited = true, fraud = true, spam = true, offensive = true, other = true }
@@ -15,9 +14,6 @@ end
for _, district in ipairs(Config.Marketplace.Districts) do
districts[district] = true
end
for _, gradient in ipairs(Config.Marketplace.PhotoGradients) do
photo_gradients[gradient] = true
end
local function trim(value)
if type(value) ~= "string" then
@@ -96,7 +92,7 @@ local function load_images(listing_id)
]], { listing_id })
end
local function validate_images(source, imei, images)
local function validate_images(source, images)
if type(images) ~= "table" or #images > Config.Marketplace.MaxImages then
return nil
end
@@ -104,30 +100,15 @@ local function validate_images(source, imei, images)
return {}
end
local owned_media = {
["sunset-drive"] = Config.Marketplace.PhotoGradients[1],
["ocean-air"] = Config.Marketplace.PhotoGradients[2],
["city-lights"] = Config.Marketplace.PhotoGradients[3],
["desert-road"] = Config.Marketplace.PhotoGradients[4],
}
local rows = Bridge.Database.Query([[
SELECT `payload` FROM `sky_phone_device_data`
WHERE `device_imei` = ? AND `namespace` = 'media'
LIMIT 1
]], { imei })
local media = rows[1] and json.decode(rows[1].payload) or nil
for _, capture in ipairs(type(media) == "table" and media.captures or {}) do
if type(capture) == "table" and type(capture.id) == "string" and photo_gradients[capture.gradient] then
owned_media[capture.id] = capture.gradient
end
end
local normalized = {}
local seen = {}
for index, image in ipairs(images) do
local media_id = type(image) == "table" and image.id or nil
local gradient = media_id and owned_media[media_id] or nil
if type(media_id) ~= "string" or #media_id > 64 or not gradient or seen[media_id] then
local numeric_id = tonumber(media_id)
local normalized_id = numeric_id and tostring(math.floor(numeric_id)) or nil
if not numeric_id or numeric_id < 1 or numeric_id ~= math.floor(numeric_id)
or seen[normalized_id]
then
Bridge.Debug(
"warn",
"[sky_phone] Rejected unowned marketplace image from source %s.",
@@ -135,8 +116,17 @@ local function validate_images(source, imei, images)
)
return nil
end
seen[media_id] = true
normalized[index] = { id = media_id, gradient = gradient }
local url = SkyPhoneMedia.ResolveOwnedMedia(source, normalized_id, "photo")
if not url then
Bridge.Debug(
"warn",
"[sky_phone] Rejected unowned marketplace image from source %s.",
tostring(source)
)
return nil
end
seen[normalized_id] = true
normalized[index] = { id = normalized_id, gradient = ("url(%s)"):format(json.encode(url)) }
end
return normalized
end
@@ -168,7 +158,7 @@ local function validate_listing(source, account, data)
return nil, "phone_unavailable"
end
local images = validate_images(source, account.imei, data.images)
local images = validate_images(source, data.images)
if not images then
return nil, "invalid_images"
end
+14 -26
View File
@@ -1,10 +1,8 @@
Bridge.Database.AfterMigration("sky_phone", function()
local categories = {}
local districts = {}
local photo_gradients = {}
for _, value in ipairs(Config.LocalPages.Categories) do categories[value] = true end
for _, value in ipairs(Config.Marketplace.Districts) do districts[value] = true end
for _, value in ipairs(Config.Marketplace.PhotoGradients) do photo_gradients[value] = true end
local function trim(value)
if type(value) ~= "string" then return nil end
@@ -33,39 +31,29 @@ local function load_images(post_id)
]], { post_id })
end
local function validate_images(source, imei, images)
local function validate_images(source, images)
if type(images) ~= "table" or #images > Config.LocalPages.MaxImages then return nil end
if #images == 0 then return {} end
local owned_media = {
["sunset-drive"] = Config.Marketplace.PhotoGradients[1],
["ocean-air"] = Config.Marketplace.PhotoGradients[2],
["city-lights"] = Config.Marketplace.PhotoGradients[3],
["desert-road"] = Config.Marketplace.PhotoGradients[4],
}
local rows = Bridge.Database.Query([[
SELECT `payload` FROM `sky_phone_device_data`
WHERE `device_imei` = ? AND `namespace` = 'media'
LIMIT 1
]], { imei })
local media = rows[1] and json.decode(rows[1].payload) or nil
for _, capture in ipairs(type(media) == "table" and media.captures or {}) do
if type(capture) == "table" and type(capture.id) == "string" and photo_gradients[capture.gradient] then
owned_media[capture.id] = capture.gradient
end
end
local normalized = {}
local seen = {}
for index, image in ipairs(images) do
local media_id = type(image) == "table" and image.id or nil
local gradient = media_id and owned_media[media_id] or nil
if type(media_id) ~= "string" or #media_id > 64 or not gradient or seen[media_id] then
local numeric_id = tonumber(media_id)
local normalized_id = numeric_id and tostring(math.floor(numeric_id)) or nil
if not numeric_id or numeric_id < 1 or numeric_id ~= math.floor(numeric_id)
or seen[normalized_id]
then
Bridge.Debug("warn", "[sky_phone] Rejected unowned Local Pages image from source %s.", tostring(source))
return nil
end
seen[media_id] = true
normalized[index] = { id = media_id, gradient = gradient }
local url = SkyPhoneMedia.ResolveOwnedMedia(source, normalized_id, "photo")
if not url then
Bridge.Debug("warn", "[sky_phone] Rejected unowned Local Pages image from source %s.", tostring(source))
return nil
end
seen[normalized_id] = true
normalized[index] = { id = normalized_id, gradient = ("url(%s)"):format(json.encode(url)) }
end
return normalized
end
@@ -189,7 +177,7 @@ Bridge.Callbacks.Register("sky_phone:pages:create", function(source, data)
then
return { success = false, error = "invalid_post" }
end
local images = validate_images(source, account.imei, data.images)
local images = validate_images(source, data.images)
if not images then return { success = false, error = "invalid_images" } end
local id = new_id()
local statements = {{