ADD - connect messages camera media

This commit is contained in:
Leon.Schmidt
2026-08-06 20:34:05 +02:00
parent 446d6b13fb
commit 4ded930845
7 changed files with 176 additions and 17 deletions
+39
View File
@@ -0,0 +1,39 @@
import { defineStore } from 'pinia'
import type { MediaType, PhoneMedia } from '@/types/media'
type MessageMediaRequest = {
mediaType: MediaType
phoneNumber: string
}
type MessageMediaResult = MessageMediaRequest & {
media: PhoneMedia
}
export const useMessageMediaStore = defineStore('message-media', {
state: () => ({
request: null as MessageMediaRequest | null,
result: null as MessageMediaResult | null,
}),
actions: {
begin(phoneNumber: string, mediaType: MediaType): void {
this.request = { mediaType, phoneNumber }
this.result = null
},
cancel(): void {
this.request = null
},
complete(media: PhoneMedia): void {
if (!this.request || this.request.mediaType !== media.mediaType) return
this.result = { ...this.request, media }
this.request = null
},
consume(phoneNumber: string): PhoneMedia | null {
if (!this.result || this.result.phoneNumber !== phoneNumber) return null
const media = this.result.media
this.result = null
return media
},
},
})
+22 -3
View File
@@ -15,8 +15,9 @@ import {
ZapOff,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import type { MediaType, PhoneMedia, UploadResult } from '@/types/media'
import { createGameView, type GameView } from '@/utils/gameView'
@@ -33,8 +34,14 @@ type CaptureItem = {
const isDevelopment = import.meta.env.DEV
const zoomLevels = [0.5, 1, 2, 3] as const
const phone = usePhoneStore()
const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const mode = ref<MediaType>('photo')
const requestedMessageMedia = computed<MediaType | null>(() => {
const value = route.query.messageAttachment
return value === 'photo' || value === 'video' ? value : null
})
const mode = ref<MediaType>(requestedMessageMedia.value ?? 'photo')
const selectedZoom = ref<(typeof zoomLevels)[number]>(1)
const flashEnabled = ref(false)
const frontCamera = ref(false)
@@ -307,6 +314,11 @@ function onMessage(event: MessageEvent): void {
if (result.success && result.media) {
latestMedia.value = result.media
updateCapture(result.correlationId, { status: 'success' })
if (requestedMessageMedia.value === result.media.mediaType) {
messageMedia.complete(result.media)
void router.replace('/apps/messages')
return
}
showCameraNotice(phone.t('Apps.camera.saved'))
window.setTimeout(() => {
captures.value = captures.value.filter(
@@ -473,7 +485,14 @@ onBeforeUnmount(() => {
class="camera-latest"
type="button"
:aria-label="phone.t('Apps.camera.openGallery')"
@click="router.push('/apps/photos')"
@click="
router.push({
path: '/apps/photos',
query: requestedMessageMedia
? { messageAttachment: requestedMessageMedia }
: undefined,
})
"
>
<img
v-if="latestMedia?.mediaType === 'photo'"
+30 -2
View File
@@ -14,7 +14,9 @@ import {
} from 'konsta/vue'
import { Play, RotateCcw, Trash2, ZoomIn, ZoomOut } from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import type { DeleteResult, GalleryFilter, PhoneMedia } from '@/types/media'
import {
@@ -35,8 +37,15 @@ const filterItems = [
{ id: 'video', label: 'videos' },
] as const
const phone = usePhoneStore()
const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const requestedMessageMedia = computed<GalleryFilter | null>(() => {
const value = route.query.messageAttachment
return value === 'photo' || value === 'video' ? value : null
})
const media = ref<PhoneMedia[]>([])
const filter = ref<GalleryFilter>('all')
const filter = ref<GalleryFilter>(requestedMessageMedia.value ?? 'all')
const loading = ref(true)
const fetching = ref(false)
const hasMore = ref(true)
@@ -193,6 +202,11 @@ function observeMore(): void {
}
function openMedia(entry: PhoneMedia): void {
if (requestedMessageMedia.value) {
messageMedia.complete(entry)
void router.replace('/apps/messages')
return
}
landscapeViewer.value = false
phone.setCameraLandscape(false)
selected.value = entry
@@ -200,6 +214,11 @@ function openMedia(entry: PhoneMedia): void {
imagePan.value = { x: 0, y: 0 }
}
function cancelMessageSelection(): void {
messageMedia.cancel()
void router.replace('/apps/messages')
}
function closeMedia(): void {
landscapeViewer.value = false
phone.setCameraLandscape(false)
@@ -326,7 +345,15 @@ onBeforeUnmount(() => {
class="gallery-page !pt-[44px]"
:aria-label="phone.t('Apps.photos.name')"
>
<k-navbar :title="phone.t('Apps.photos.name')" />
<k-navbar :title="phone.t('Apps.photos.name')">
<template v-if="requestedMessageMedia" #left>
<k-navbar-back-link
component="button"
:text="phone.t('Common.back')"
@click="cancelMessageSelection"
/>
</template>
</k-navbar>
<div class="gallery-content">
<div v-if="loading" class="gallery-state">
@@ -385,6 +412,7 @@ onBeforeUnmount(() => {
</div>
<k-navbar
v-if="!requestedMessageMedia"
component="nav"
class="gallery-filter-navbar"
:aria-label="phone.t('Apps.photos.name')"
+26 -3
View File
@@ -38,12 +38,14 @@ import {
X,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
import VoiceMessageBubble from '@/components/VoiceMessageBubble.vue'
import { useCallsStore } from '@/stores/calls'
import { useMessagesStore } from '@/stores/messages'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
import type {
@@ -60,6 +62,8 @@ const WAVEFORM_SAMPLES = 48
const phone = usePhoneStore()
const calls = useCallsStore()
const messages = useMessagesStore()
const messageMedia = useMessageMediaStore()
const router = useRouter()
const search = ref('')
const showUnreadOnly = ref(false)
const editingList = ref(false)
@@ -427,6 +431,16 @@ function openEmojiPicker(): void {
emojiOpen.value = true
}
function openMediaApp(app: 'camera' | 'photos', mediaType: 'photo' | 'video'): void {
if (!messages.activeNumber) return
attachmentMenuOpen.value = false
messageMedia.begin(messages.activeNumber, mediaType)
void router.push({
path: `/apps/${app}`,
query: { messageAttachment: mediaType },
})
}
async function sendAttachment(
messageType: SmsAttachmentType,
mediaAssetId: string,
@@ -652,6 +666,15 @@ async function finishVoiceRecording(): Promise<void> {
onMounted(() => {
void messages.loadConversations()
void calls.loadContacts()
if (messages.activeNumber) {
const media = messageMedia.consume(messages.activeNumber)
if (media) {
void sendAttachment(
media.mediaType === 'photo' ? 'image' : 'video',
import.meta.env.DEV ? media.url : String(media.id),
)
}
}
})
onBeforeUnmount(() => {
@@ -1035,11 +1058,11 @@ onBeforeUnmount(() => {
</k-messages>
<section v-if="attachmentMenuOpen" class="messages-attachment-menu">
<button type="button" aria-disabled="true">
<button type="button" @click="openMediaApp('photos', 'photo')">
<span><Images :size="20" /></span>
{{ phone.t('Apps.messages.attachPhoto') }}
</button>
<button type="button" aria-disabled="true">
<button type="button" @click="openMediaApp('camera', 'photo')">
<span><Camera :size="20" /></span>
{{ phone.t('Apps.messages.takePhoto') }}
</button>
@@ -1051,7 +1074,7 @@ onBeforeUnmount(() => {
<span><ImagePlay :size="20" /></span>
{{ phone.t('Apps.messages.attachGif') }}
</button>
<button type="button" aria-disabled="true">
<button type="button" @click="openMediaApp('photos', 'video')">
<span><Video :size="20" /></span>
{{ phone.t('Apps.messages.attachVideo') }}
</button>
+7 -2
View File
@@ -956,14 +956,19 @@ app.post('/api/:endpoint', (request, response) => {
const phoneNumber = String(request.body.phoneNumber ?? '')
const messageType = request.body.messageType ?? 'text'
const isAttachment = ['image', 'gif', 'video'].includes(messageType)
const attachmentId = String(request.body.mediaAssetId ?? '')
const requestedAttachmentId = String(request.body.mediaAssetId ?? '')
const selectedMedia = /^\d+$/.test(requestedAttachmentId)
? mockMedia.find((item) => String(item.id) === requestedAttachmentId)
: null
const attachmentId = selectedMedia?.url ?? requestedAttachmentId
if (
!phoneNumber ||
(messageType === 'text' && !body) ||
(messageType === 'voice' && !request.body.mediaPayload) ||
(isAttachment &&
!attachmentAssets[messageType].has(attachmentId) &&
!attachmentId.startsWith('https://'))
!attachmentId.startsWith('https://') &&
!attachmentId.startsWith('data:image/'))
) {
response.json({ success: false, error: 'invalid_message' })
return
+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-DcCLG64c.js"></script>
<script type="module" crossorigin src="./assets/sky-index-D8a3rSQd.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-BInJdX1o.css">
</head>
<body>
+51 -6
View File
@@ -57,6 +57,16 @@ local function valid_attachment_asset(message_type, value)
or message_type == "gif" and allowed_media_url(value)
end
local function valid_stored_attachment(message_type, value)
if valid_attachment_asset(message_type, value) then
return true
end
return (message_type == "image" or message_type == "video")
and type(value) == "string"
and #value <= Config.Media.UrlMaxLength
and value:match("^https://") ~= nil
end
local function uuid()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
if not rows[1] or type(rows[1].id) ~= "string" then
@@ -98,7 +108,7 @@ local function format_message(row)
row.media_waveform = waveform
row.media_payload = nil
elseif attachment_assets[row.message_type] then
if not valid_attachment_asset(row.message_type, row.media_payload) then
if not valid_stored_attachment(row.message_type, row.media_payload) then
error(("[sky_phone] Message %s has an invalid attachment asset."):format(tostring(row.id)))
end
row.media_asset_id = row.media_payload
@@ -116,12 +126,47 @@ local function format_message(row)
return row
end
local function validate_attachment(message_type, data)
if type(data.mediaAssetId) ~= "string" or not valid_attachment_asset(message_type, data.mediaAssetId) then
local function validate_attachment(source, device, message_type, data)
if type(data.mediaAssetId) ~= "string" then
return nil
end
local payload = data.mediaAssetId
if not valid_attachment_asset(message_type, payload) then
if message_type == "gif" then
return nil
end
local media_id = tonumber(payload)
if not media_id or media_id < 1 or media_id ~= math.floor(media_id) then
return nil
end
local condition
local params
if device.account_id then
condition = "`account_id` = ?"
params = { media_id, tonumber(device.account_id) }
else
condition = "`account_id` IS NULL AND `device_imei` = ?"
params = { media_id, device.imei }
end
local rows = Bridge.Database.Query(([[
SELECT `url`, `media_type` FROM `sky_phone_media`
WHERE `id` = ? AND %s
LIMIT 1
]]):format(condition), params)
local media = rows[1]
local expected_type = message_type == "image" and "photo" or "video"
if not media or media.media_type ~= expected_type
or type(media.url) ~= "string"
or #media.url > Config.Media.UrlMaxLength
or not media.url:match("^https://")
then
Bridge.Debug("warn", ("[sky_phone] Rejected unowned SMS media from source %s."):format(tostring(source)))
return nil
end
payload = media.url
end
local duration = nil
if message_type == "video" then
if message_type == "video" and data.mediaDurationMs ~= nil then
duration = tonumber(data.mediaDurationMs)
if not duration or duration < 1000 or duration > Config.Messages.VideoMaxDurationMs then
return nil
@@ -131,7 +176,7 @@ local function validate_attachment(message_type, data)
return {
duration = duration,
mime = attachment_mimes[message_type],
payload = data.mediaAssetId,
payload = payload,
}
end
@@ -380,7 +425,7 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
end
body = ""
elseif attachment_assets[message_type] then
attachment = validate_attachment(message_type, data)
attachment = validate_attachment(source, device, message_type, data)
if not attachment then
return { success = false, error = "invalid_attachment" }
end