feat(frontend): optimizations for slow internet situations
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { Id } from '$lib/convex/_generated/dataModel';
|
||||||
|
|
||||||
interface Photo {
|
interface Photo {
|
||||||
base64: string;
|
_id: Id<'photoDrafts'>;
|
||||||
mediaType: string;
|
mediaType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getContext, setContext } from 'svelte';
|
|||||||
import type { FunctionReference, FunctionArgs, FunctionReturnType } from 'convex/server';
|
import type { FunctionReference, FunctionArgs, FunctionReturnType } from 'convex/server';
|
||||||
|
|
||||||
const POLLING_CONTEXT_KEY = 'convex-polling';
|
const POLLING_CONTEXT_KEY = 'convex-polling';
|
||||||
const POLL_INTERVAL = 500;
|
const POLL_INTERVAL = 1000;
|
||||||
|
|
||||||
type PollingContext = {
|
type PollingContext = {
|
||||||
client: ConvexHttpClient;
|
client: ConvexHttpClient;
|
||||||
@@ -47,7 +47,11 @@ export function usePollingMutation<Mutation extends FunctionReference<'mutation'
|
|||||||
export function usePollingQuery<Query extends FunctionReference<'query'>>(
|
export function usePollingQuery<Query extends FunctionReference<'query'>>(
|
||||||
query: Query,
|
query: Query,
|
||||||
argsGetter: () => FunctionArgs<Query> | 'skip'
|
argsGetter: () => FunctionArgs<Query> | 'skip'
|
||||||
): { data: FunctionReturnType<Query> | undefined; error: Error | null; isLoading: boolean } {
|
): {
|
||||||
|
data: FunctionReturnType<Query> | undefined;
|
||||||
|
error: Error | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
} {
|
||||||
const client = usePollingClient();
|
const client = usePollingClient();
|
||||||
|
|
||||||
// eslint-disable-next-line prefer-const
|
// eslint-disable-next-line prefer-const
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ export const listByChat = query({
|
|||||||
chatId: v.id('chats'),
|
chatId: v.id('chats'),
|
||||||
role: v.union(v.literal('user'), v.literal('assistant')),
|
role: v.union(v.literal('user'), v.literal('assistant')),
|
||||||
content: v.string(),
|
content: v.string(),
|
||||||
imageBase64: v.optional(v.string()),
|
|
||||||
imageMediaType: v.optional(v.string()),
|
|
||||||
imagesBase64: v.optional(v.array(v.string())),
|
|
||||||
imagesMediaTypes: v.optional(v.array(v.string())),
|
|
||||||
followUpOptions: v.optional(v.array(v.string())),
|
followUpOptions: v.optional(v.array(v.string())),
|
||||||
source: v.union(v.literal('telegram'), v.literal('web')),
|
source: v.union(v.literal('telegram'), v.literal('web')),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
@@ -22,11 +18,23 @@ export const listByChat = query({
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
return await ctx.db
|
const messages = await ctx.db
|
||||||
.query('messages')
|
.query('messages')
|
||||||
.withIndex('by_chat_id_and_created_at', (q) => q.eq('chatId', args.chatId))
|
.withIndex('by_chat_id_and_created_at', (q) => q.eq('chatId', args.chatId))
|
||||||
.order('asc')
|
.order('asc')
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
return messages.map((m) => ({
|
||||||
|
_id: m._id,
|
||||||
|
_creationTime: m._creationTime,
|
||||||
|
chatId: m.chatId,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
followUpOptions: m.followUpOptions,
|
||||||
|
source: m.source,
|
||||||
|
createdAt: m.createdAt,
|
||||||
|
isStreaming: m.isStreaming
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export const get = query({
|
|||||||
photos: v.array(
|
photos: v.array(
|
||||||
v.object({
|
v.object({
|
||||||
_id: v.id('photoDrafts'),
|
_id: v.id('photoDrafts'),
|
||||||
base64: v.string(),
|
|
||||||
mediaType: v.string()
|
mediaType: v.string()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -28,7 +27,6 @@ export const get = query({
|
|||||||
return {
|
return {
|
||||||
photos: drafts.map((d) => ({
|
photos: drafts.map((d) => ({
|
||||||
_id: d._id,
|
_id: d._id,
|
||||||
base64: d.base64,
|
|
||||||
mediaType: d.mediaType
|
mediaType: d.mediaType
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,20 @@ const photoRequestValidator = v.object({
|
|||||||
createdAt: v.number()
|
createdAt: v.number()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const photoRequestLightValidator = v.object({
|
||||||
|
_id: v.id('photoRequests'),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('pending'),
|
||||||
|
v.literal('countdown'),
|
||||||
|
v.literal('capture_now'),
|
||||||
|
v.literal('captured'),
|
||||||
|
v.literal('accepted'),
|
||||||
|
v.literal('rejected')
|
||||||
|
),
|
||||||
|
photoMediaType: v.optional(v.string()),
|
||||||
|
thumbnailBase64: v.optional(v.string())
|
||||||
|
});
|
||||||
|
|
||||||
export const create = mutation({
|
export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
chatId: v.id('chats'),
|
chatId: v.id('chats'),
|
||||||
@@ -85,9 +99,14 @@ export const markRejected = mutation({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const captureNowLightValidator = v.object({
|
||||||
|
_id: v.id('photoRequests'),
|
||||||
|
status: v.literal('capture_now')
|
||||||
|
});
|
||||||
|
|
||||||
export const getCaptureNowRequest = query({
|
export const getCaptureNowRequest = query({
|
||||||
args: { chatId: v.id('chats'), deviceId: v.optional(v.string()) },
|
args: { chatId: v.id('chats'), deviceId: v.optional(v.string()) },
|
||||||
returns: v.union(photoRequestValidator, v.null()),
|
returns: v.union(captureNowLightValidator, v.null()),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const requests = await ctx.db
|
const requests = await ctx.db
|
||||||
.query('photoRequests')
|
.query('photoRequests')
|
||||||
@@ -95,7 +114,10 @@ export const getCaptureNowRequest = query({
|
|||||||
.order('desc')
|
.order('desc')
|
||||||
.take(50);
|
.take(50);
|
||||||
|
|
||||||
return requests.find((r) => r.status === 'capture_now') ?? null;
|
const found = requests.find((r) => r.status === 'capture_now');
|
||||||
|
if (!found) return null;
|
||||||
|
|
||||||
|
return { _id: found._id, status: 'capture_now' as const };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +137,7 @@ export const getActiveForCapture = query({
|
|||||||
|
|
||||||
export const getMyActiveRequest = query({
|
export const getMyActiveRequest = query({
|
||||||
args: { chatId: v.id('chats'), deviceId: v.optional(v.string()) },
|
args: { chatId: v.id('chats'), deviceId: v.optional(v.string()) },
|
||||||
returns: v.union(photoRequestValidator, v.null()),
|
returns: v.union(photoRequestLightValidator, v.null()),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const requests = await ctx.db
|
const requests = await ctx.db
|
||||||
.query('photoRequests')
|
.query('photoRequests')
|
||||||
@@ -124,13 +146,89 @@ export const getMyActiveRequest = query({
|
|||||||
|
|
||||||
if (!args.deviceId) return null;
|
if (!args.deviceId) return null;
|
||||||
|
|
||||||
return (
|
const found = requests.find(
|
||||||
requests.find(
|
|
||||||
(r) =>
|
(r) =>
|
||||||
r.requesterId === args.deviceId &&
|
r.requesterId === args.deviceId &&
|
||||||
(r.status === 'countdown' || r.status === 'capture_now' || r.status === 'captured')
|
(r.status === 'countdown' || r.status === 'capture_now' || r.status === 'captured')
|
||||||
) ?? null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!found) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: found._id,
|
||||||
|
status: found.status,
|
||||||
|
photoMediaType: found.photoMediaType,
|
||||||
|
thumbnailBase64: found.thumbnailBase64
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPhotoData = query({
|
||||||
|
args: { requestId: v.id('photoRequests') },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
photoBase64: v.string(),
|
||||||
|
photoMediaType: v.string(),
|
||||||
|
thumbnailBase64: v.optional(v.string())
|
||||||
|
}),
|
||||||
|
v.null()
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const req = await ctx.db.get(args.requestId);
|
||||||
|
if (!req || !req.photoBase64 || !req.photoMediaType) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
photoBase64: req.photoBase64,
|
||||||
|
photoMediaType: req.photoMediaType,
|
||||||
|
thumbnailBase64: req.thumbnailBase64
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPhotoPreview = query({
|
||||||
|
args: { requestId: v.id('photoRequests') },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
thumbnailBase64: v.string(),
|
||||||
|
photoMediaType: v.string()
|
||||||
|
}),
|
||||||
|
v.null()
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const req = await ctx.db.get(args.requestId);
|
||||||
|
if (!req || !req.photoMediaType) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
thumbnailBase64: req.thumbnailBase64 || req.photoBase64 || '',
|
||||||
|
photoMediaType: req.photoMediaType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const acceptPhotoToDraft = mutation({
|
||||||
|
args: {
|
||||||
|
requestId: v.id('photoRequests'),
|
||||||
|
chatId: v.id('chats'),
|
||||||
|
deviceId: v.string()
|
||||||
|
},
|
||||||
|
returns: v.id('photoDrafts'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const req = await ctx.db.get(args.requestId);
|
||||||
|
if (!req || !req.photoBase64 || !req.photoMediaType) {
|
||||||
|
throw new Error('Photo request not found or has no photo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftId = await ctx.db.insert('photoDrafts', {
|
||||||
|
chatId: args.chatId,
|
||||||
|
deviceId: args.deviceId,
|
||||||
|
base64: req.photoBase64,
|
||||||
|
mediaType: req.photoMediaType,
|
||||||
|
createdAt: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(args.requestId, { status: 'accepted' });
|
||||||
|
|
||||||
|
return draftId;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
import { getContext, onMount } from 'svelte';
|
import { getContext, onMount } from 'svelte';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
import { usePollingQuery, usePollingMutation } from '$lib/convex-polling.svelte';
|
import {
|
||||||
|
usePollingQuery,
|
||||||
|
usePollingMutation,
|
||||||
|
usePollingClient
|
||||||
|
} from '$lib/convex-polling.svelte';
|
||||||
import { api } from '$lib/convex/_generated/api';
|
import { api } from '$lib/convex/_generated/api';
|
||||||
import type { Id } from '$lib/convex/_generated/dataModel';
|
import type { Id } from '$lib/convex/_generated/dataModel';
|
||||||
import ChatMessage from '$lib/components/ChatMessage.svelte';
|
import ChatMessage from '$lib/components/ChatMessage.svelte';
|
||||||
@@ -30,7 +34,6 @@
|
|||||||
let activeRequestId: Id<'photoRequests'> | null = $state(null);
|
let activeRequestId: Id<'photoRequests'> | null = $state(null);
|
||||||
let previewPhoto: {
|
let previewPhoto: {
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
fullBase64: string;
|
|
||||||
mediaType: string;
|
mediaType: string;
|
||||||
requestId: Id<'photoRequests'>;
|
requestId: Id<'photoRequests'>;
|
||||||
} | null = $state(null);
|
} | null = $state(null);
|
||||||
@@ -206,17 +209,24 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const req = myActiveRequest.data;
|
const req = myActiveRequest.data;
|
||||||
if (req?.status === 'captured' && req.photoBase64 && req.photoMediaType) {
|
if (req?.status === 'captured' && req.photoMediaType) {
|
||||||
if (shownPreviewIds.has(req._id)) return;
|
if (shownPreviewIds.has(req._id)) return;
|
||||||
shownPreviewIds.add(req._id);
|
shownPreviewIds.add(req._id);
|
||||||
|
|
||||||
|
const client = pollingClient ?? clientWs;
|
||||||
|
if (client) {
|
||||||
|
client.query(api.photoRequests.getPhotoPreview, { requestId: req._id }).then((data) => {
|
||||||
|
if (data) {
|
||||||
previewPhoto = {
|
previewPhoto = {
|
||||||
thumbnail: req.thumbnailBase64 || req.photoBase64,
|
thumbnail: data.thumbnailBase64,
|
||||||
fullBase64: req.photoBase64,
|
mediaType: data.photoMediaType,
|
||||||
mediaType: req.photoMediaType,
|
|
||||||
requestId: req._id
|
requestId: req._id
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let prevMessageCount = 0;
|
let prevMessageCount = 0;
|
||||||
let prevLastMessageId: string | undefined;
|
let prevLastMessageId: string | undefined;
|
||||||
@@ -232,6 +242,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const clientWs = usePolling ? null : useConvexClient();
|
const clientWs = usePolling ? null : useConvexClient();
|
||||||
|
const pollingClient = usePolling ? usePollingClient() : null;
|
||||||
const createMessagePoll = usePolling ? usePollingMutation(api.messages.create) : null;
|
const createMessagePoll = usePolling ? usePollingMutation(api.messages.create) : null;
|
||||||
const registerDevicePoll = usePolling ? usePollingMutation(api.devicePairings.register) : null;
|
const registerDevicePoll = usePolling ? usePollingMutation(api.devicePairings.register) : null;
|
||||||
const heartbeatPoll = usePolling ? usePollingMutation(api.devicePairings.heartbeat) : null;
|
const heartbeatPoll = usePolling ? usePollingMutation(api.devicePairings.heartbeat) : null;
|
||||||
@@ -246,8 +257,10 @@
|
|||||||
? usePollingMutation(api.photoRequests.markCaptureNow)
|
? usePollingMutation(api.photoRequests.markCaptureNow)
|
||||||
: null;
|
: null;
|
||||||
const submitPhotoPoll = usePolling ? usePollingMutation(api.photoRequests.submitPhoto) : null;
|
const submitPhotoPoll = usePolling ? usePollingMutation(api.photoRequests.submitPhoto) : null;
|
||||||
const markAcceptedPoll = usePolling ? usePollingMutation(api.photoRequests.markAccepted) : null;
|
|
||||||
const markRejectedPoll = usePolling ? usePollingMutation(api.photoRequests.markRejected) : null;
|
const markRejectedPoll = usePolling ? usePollingMutation(api.photoRequests.markRejected) : null;
|
||||||
|
const acceptPhotoToDraftPoll = usePolling
|
||||||
|
? usePollingMutation(api.photoRequests.acceptPhotoToDraft)
|
||||||
|
: null;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!chatId || !deviceId) return;
|
if (!chatId || !deviceId) return;
|
||||||
@@ -467,21 +480,16 @@
|
|||||||
function handlePreviewAccept() {
|
function handlePreviewAccept() {
|
||||||
if (!previewPhoto || !chatId) return;
|
if (!previewPhoto || !chatId) return;
|
||||||
|
|
||||||
const photo = { base64: previewPhoto.fullBase64, mediaType: previewPhoto.mediaType };
|
|
||||||
const reqId = previewPhoto.requestId;
|
const reqId = previewPhoto.requestId;
|
||||||
previewPhoto = null;
|
previewPhoto = null;
|
||||||
|
|
||||||
if (usePolling && addPhotoPoll && markAcceptedPoll) {
|
if (usePolling && acceptPhotoToDraftPoll) {
|
||||||
addPhotoPoll({ chatId, deviceId, photo });
|
acceptPhotoToDraftPoll({ requestId: reqId, chatId, deviceId });
|
||||||
markAcceptedPoll({ requestId: reqId });
|
|
||||||
} else if (clientWs) {
|
} else if (clientWs) {
|
||||||
clientWs.mutation(api.photoDrafts.addPhoto, {
|
clientWs.mutation(api.photoRequests.acceptPhotoToDraft, {
|
||||||
|
requestId: reqId,
|
||||||
chatId,
|
chatId,
|
||||||
deviceId,
|
deviceId
|
||||||
photo
|
|
||||||
});
|
|
||||||
clientWs.mutation(api.photoRequests.markAccepted, {
|
|
||||||
requestId: reqId
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ import { defineConfig } from 'vite';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
server: { allowedHosts: ['porter-davidson-fibre-handhelds.trycloudflare.com'] }
|
server: { allowedHosts: ['reasonable-duncan-stations-parking.trycloudflare.com'] }
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user