mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-31 10:18:57 +00:00
ADD - connect messages camera media
This commit is contained in:
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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'"
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user