diff --git a/README.md b/README.md index 025b0d7..6c4b500 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,19 @@ From `frontend/`, run `pnpm dev` for browser development. The phone opens automa - Profile onboarding: `http://localhost:5174/?apiPort=3002&testScenario=feather-onboarding#/apps/feather` - Empty states: `http://localhost:5174/?apiPort=3002&testScenario=feather-empty#/apps/feather` +EasyShare browser data is available from every app that exposes a share action. Open the seeded +Gallery directly, share an item, and then use the EasyShare and History actions in the sheet: + +- Full data, including incoming, transferring, accepted, completed, declined, cancelled, expired, + and failed transfers: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-full#/apps/gallery` +- Incoming request only: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-incoming#/apps/gallery` +- Transfer history without active requests: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-history#/apps/gallery` +- Empty nearby and history states: `http://localhost:5174/?apiPort=3002&testScenario=easyshare-empty#/apps/gallery` + +In the full scenario, sending to Mia or Jamie creates a pending transfer. Sending to Noah creates a +transfer at 58 percent so the progress and cancel states can be tested. Visibility changes and +accepting or declining the seeded incoming request are kept in memory until the mock server restarts. + The full-data scenario includes posts, replies, quotes, media grids, profiles, ranked hashtags, network search results, and every notification type. Run `pnpm test`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` before packaging. `pnpm build` uses `build.cjs` to replace `sky_phone/source/html` deterministically with the Vite output. Production assets use relative paths so they work through the FiveM NUI protocol. diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index d727a51..5b9a211 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -4895,6 +4895,53 @@ button { gap: 7px; overflow-y: auto; } +.shared-composer-preview { + position: fixed; + z-index: 43; + right: 12px; + bottom: 65px; + left: 12px; + padding: 7px; + border: 1px solid rgb(255 255 255 / 72%); + border-radius: 19px; + background: rgb(242 242 247 / 90%); + box-shadow: 0 10px 28px rgb(20 44 76 / 18%); + backdrop-filter: blur(24px) saturate(1.6); + -webkit-backdrop-filter: blur(24px) saturate(1.6); +} +.shared-composer-preview > button { + position: absolute; + z-index: 2; + top: 2px; + right: 2px; + width: 24px; + height: 24px; + border-radius: 50%; + display: grid; + place-items: center; + color: white; + background: rgb(60 60 67 / 78%); +} +.shared-composer-preview--dark { + position: relative; + inset: auto; + z-index: 2; + margin: 0 8px 7px; + flex: 0 0 auto; + border-color: rgb(255 255 255 / 10%); + background: rgb(20 18 27 / 94%); + box-shadow: 0 8px 22px rgb(0 0 0 / 28%); +} +.phone-app.dark .shared-composer-preview { + border-color: rgb(255 255 255 / 10%); + background: rgb(36 36 40 / 92%); +} +.messages-media-picker__contacts { + height: 270px; + margin: 0; + padding: 8px 0 18px; + overflow-y: auto; +} .messages-media-picker__gifs button { min-height: 83px; display: flex; @@ -6106,6 +6153,18 @@ button { display: block; margin: 0; } +.messages-thread-page .messages-attachment-menu > button:nth-child(4) > span { + background: #e9f9ed; + color: #28a745; +} +.messages-thread-page .messages-attachment-menu > button:nth-child(5) > span { + background: #f6eefe; + color: #af52de; +} +.messages-thread-page .messages-attachment-menu > button:nth-child(6) > span { + background: #fff0ef; + color: #ff3b30; +} .messages-thread-page .messages-messagebar > .k-toolbar diff --git a/frontend/src/components/MessageContactBubble.vue b/frontend/src/components/MessageContactBubble.vue new file mode 100644 index 0000000..0ee78bc --- /dev/null +++ b/frontend/src/components/MessageContactBubble.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/frontend/src/components/SharedContentCard.vue b/frontend/src/components/SharedContentCard.vue new file mode 100644 index 0000000..4dc9958 --- /dev/null +++ b/frontend/src/components/SharedContentCard.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/frontend/src/stores/darkchat.ts b/frontend/src/stores/darkchat.ts index 47a3b9b..64c51ad 100644 --- a/frontend/src/stores/darkchat.ts +++ b/frontend/src/stores/darkchat.ts @@ -95,6 +95,8 @@ export const useDarkChatStore = defineStore('darkchat', () => { messageType: outgoing.messageType, reactions: {}, replyToId: outgoing.replyToId, + sharePayload: + outgoing.messageType === 'share' ? outgoing.sharePayload : null, } messages.value.push(optimistic) if (outgoing.messageType === 'voice' && outgoing.mediaPayload) { diff --git a/frontend/src/stores/easyshare.test.ts b/frontend/src/stores/easyshare.test.ts index ef63001..4dcdd14 100644 --- a/frontend/src/stores/easyshare.test.ts +++ b/frontend/src/stores/easyshare.test.ts @@ -85,6 +85,7 @@ describe('easyshare store', () => { expect(easyShare.consumeChatDraft('messages')).toEqual({ appId: 'messages', body: 'Meet at Mission Row.\nhttps://notes.sky/note-1', + payload: { ...payload, link: 'https://notes.sky/note-1' }, targetId: '5551234567', }) expect(easyShare.consumeChatDraft('messages')).toBeNull() @@ -98,6 +99,7 @@ describe('easyshare store', () => { expect(easyShare.consumeChatDraft('darkchat')).toEqual({ appId: 'darkchat', body: 'Meet at Mission Row.', + payload, targetId: null, }) }) diff --git a/frontend/src/stores/easyshare.ts b/frontend/src/stores/easyshare.ts index 770c012..60db644 100644 --- a/frontend/src/stores/easyshare.ts +++ b/frontend/src/stores/easyshare.ts @@ -56,6 +56,7 @@ export const useEasyShareStore = defineStore('easyshare', () => { chatDraft.value = { appId, body: [...new Set(parts)].join('\n'), + payload: { ...payload.value }, targetId, } return Boolean(chatDraft.value.body) diff --git a/frontend/src/stores/messages.test.ts b/frontend/src/stores/messages.test.ts index 4074b47..90986a0 100644 --- a/frontend/src/stores/messages.test.ts +++ b/frontend/src/stores/messages.test.ts @@ -78,6 +78,47 @@ describe('messages store', () => { expect(messages.messages[0].delivery_status).toBe('failed') }) + it('shows a shared contact immediately and sends only its id', async () => { + const contact = { + avatar_url: 'https://picsum.photos/seed/shared-alex/240/240', + name: 'Alex Rivera', + organization: 'Maze Bank', + phone_number: '4205550137', + } + const serverMessage: SmsMessage = { + ...sentMessage('contact-server-id'), + body: contact.name, + contact, + message_type: 'contact', + } + mockNuiCall + .mockResolvedValueOnce({ data: [], success: true }) + .mockResolvedValueOnce({ data: [], success: true }) + .mockResolvedValueOnce({ data: serverMessage, success: true }) + .mockResolvedValueOnce({ data: [], success: true }) + + const messages = useMessagesStore() + await messages.openThread('4205550196') + const sending = messages.send({ + contact, + contactId: 'contact-alex', + messageType: 'contact', + }) + + expect(messages.messages[0]).toMatchObject({ + contact, + delivery_status: 'sending', + message_type: 'contact', + }) + + await sending + expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'messages:send', { + contactId: 'contact-alex', + messageType: 'contact', + phoneNumber: '4205550196', + }) + }) + it('normalizes numeric phone data from the NUI boundary', async () => { mockNuiCall.mockResolvedValueOnce({ data: [ diff --git a/frontend/src/stores/messages.ts b/frontend/src/stores/messages.ts index fd18982..749f674 100644 --- a/frontend/src/stores/messages.ts +++ b/frontend/src/stores/messages.ts @@ -57,8 +57,12 @@ export const useMessagesStore = defineStore('messages', () => { if (!activeNumber.value) return { success: false, error: 'invalid_number' } const clientId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const optimistic: SmsMessage = { - body: outgoing.messageType === 'text' ? outgoing.body.trim() : '', + body: + outgoing.messageType === 'text' || outgoing.messageType === 'share' + ? outgoing.body?.trim() ?? '' + : '', client_id: clientId, + contact: outgoing.messageType === 'contact' ? outgoing.contact : null, created_at: new Date().toISOString().slice(0, 19).replace('T', ' '), delivery_status: 'sending', direction: 'sent', @@ -78,6 +82,7 @@ export const useMessagesStore = defineStore('messages', () => { media_waveform: outgoing.messageType === 'voice' ? outgoing.mediaWaveform : null, message_type: outgoing.messageType, + share: outgoing.messageType === 'share' ? outgoing.sharePayload : null, read_at: null, recipient_number: activeNumber.value, sender_number: '', @@ -88,10 +93,19 @@ export const useMessagesStore = defineStore('messages', () => { `data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}` } - const response = await nuiCall('messages:send', { - ...outgoing, - phoneNumber: activeNumber.value, - }) + const response = await nuiCall( + 'messages:send', + outgoing.messageType === 'contact' + ? { + contactId: outgoing.contactId, + messageType: outgoing.messageType, + phoneNumber: activeNumber.value, + } + : { + ...outgoing, + phoneNumber: activeNumber.value, + }, + ) const index = messages.value.findIndex( (message) => message.client_id === clientId, ) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index dc41171..3ad36db 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -47,6 +47,20 @@ const defaultLocales: LocaleTree = { destinations: 'Share destinations', newMessage: 'New Message', shareProfile: 'Share Profile', + kinds: { + contact: 'Contact', + document: 'Document', + link: 'Link', + location: 'Location', + note: 'Note', + photo: 'Photo', + playlist: 'Playlist', + post: 'Post', + profile: 'Profile', + text: 'Message', + track: 'Track', + video: 'Video', + }, chooseConversation: 'Choose a conversation', sentToChat: 'Sent to chat.', savedToNotes: 'Saved to Notes.', @@ -1122,6 +1136,7 @@ const defaultLocales: LocaleTree = { photo: 'Photo', gif: 'GIF', video: 'Video', + contact: 'Contact', attachPhoto: 'Attach Photo', takePhoto: 'Take Photo', attachGif: 'Attach GIF', @@ -1129,6 +1144,10 @@ const defaultLocales: LocaleTree = { photos: 'Photos', gifs: 'GIFs', videos: 'Videos', + contacts: 'Contacts', + shareContact: 'Share Contact', + noContactsToShare: 'Save a contact first to share it here.', + contactSaved: 'Contact Saved', noPhotos: 'Take a photo first to attach it here.', noVideos: 'Record a video in Camera first.', searchGifs: 'Search GIPHY', @@ -1172,6 +1191,8 @@ const defaultLocales: LocaleTree = { invalid_message: 'Enter a message.', invalid_voice: 'The audio message is invalid.', invalid_attachment: 'The attachment is invalid.', + invalid_contact: 'The contact is invalid.', + contact_not_found: 'This contact is no longer available.', media_provider_unconfigured: 'Photo uploads are not configured.', capture_provider_unavailable: 'The screenshot resource is unavailable.', capture_failed: 'The photo could not be captured.', diff --git a/frontend/src/types/darkchat.ts b/frontend/src/types/darkchat.ts index 05dcf0a..b79b812 100644 --- a/frontend/src/types/darkchat.ts +++ b/frontend/src/types/darkchat.ts @@ -1,4 +1,5 @@ import type { DatabaseDateValue } from '@/utils/date' +import type { EasySharePayload } from '@/types/easyshare' export type DarkChatMessageType = | 'text' @@ -7,6 +8,7 @@ export type DarkChatMessageType = | 'voice' | 'image' | 'video' + | 'share' | 'system' export type DarkChatNotificationMode = 'full' | 'private' | 'hidden' @@ -68,6 +70,7 @@ export type DarkChatMessage = { replyToId?: string | null replyBody?: string | null reactions: Record + sharePayload?: EasySharePayload | null expiresAt?: DatabaseDateValue | null createdAt: DatabaseDateValue readAt?: DatabaseDateValue | null @@ -88,12 +91,13 @@ export type DarkChatThread = { export type DarkChatOutgoing = { body?: string - messageType: 'text' | 'emoji' | 'gif' | 'voice' | 'image' | 'video' + messageType: 'text' | 'emoji' | 'gif' | 'voice' | 'image' | 'video' | 'share' mediaAssetId?: string mediaPayload?: string mediaPreviewUrl?: string mediaMime?: string mediaDurationMs?: number mediaWaveform?: number[] + sharePayload?: EasySharePayload replyToId?: string } diff --git a/frontend/src/types/easyshare.ts b/frontend/src/types/easyshare.ts index ed63d73..368883c 100644 --- a/frontend/src/types/easyshare.ts +++ b/frontend/src/types/easyshare.ts @@ -19,6 +19,7 @@ export type EasyShareChatApp = 'darkchat' | 'flare' | 'messages' export type EasyShareChatDraft = { appId: EasyShareChatApp body: string + payload: EasySharePayload targetId: string | null } export type EasyShareStatus = diff --git a/frontend/src/types/flare.ts b/frontend/src/types/flare.ts index 1ae8689..60900d0 100644 --- a/frontend/src/types/flare.ts +++ b/frontend/src/types/flare.ts @@ -1,8 +1,9 @@ import type { DatabaseDateValue } from '@/utils/date' +import type { EasySharePayload } from '@/types/easyshare' export type FlareGender = 'woman' | 'man' | 'nonbinary' export type FlareInterest = FlareGender | 'everyone' -export type FlareMessageType = 'text' | 'image' | 'gif' | 'video' +export type FlareMessageType = 'text' | 'image' | 'gif' | 'video' | 'share' export type FlareProfile = { age: number @@ -43,14 +44,16 @@ export type FlareMessage = { mediaDurationMs: number | null mediaUrl: string | null messageType: FlareMessageType + sharePayload: EasySharePayload | null } export type FlareOutgoingMessage = | { body: string; messageType: 'text' } + | { body?: string; messageType: 'share'; sharePayload: EasySharePayload } | { mediaAssetId: string mediaDurationMs?: number - messageType: Exclude + messageType: Exclude } export type FlareProfileDraft = Omit< diff --git a/frontend/src/types/messages.ts b/frontend/src/types/messages.ts index 02748d8..5a23feb 100644 --- a/frontend/src/types/messages.ts +++ b/frontend/src/types/messages.ts @@ -1,10 +1,23 @@ import type { DatabaseDateValue } from '@/utils/date' +import type { EasySharePayload } from '@/types/easyshare' export type SmsDirection = 'sent' | 'received' export type SmsAttachmentType = 'image' | 'gif' | 'video' -export type SmsMessageType = 'text' | 'voice' | SmsAttachmentType +export type SmsMessageType = + | 'contact' + | 'share' + | 'text' + | 'voice' + | SmsAttachmentType export type SmsDeliveryStatus = 'sending' | 'delivered' | 'failed' +export type SmsSharedContact = { + avatar_url: string | null + name: string + organization: string | null + phone_number: string +} + export type SmsConversation = { lastMessage: string lastMessageAt: DatabaseDateValue @@ -16,6 +29,7 @@ export type SmsConversation = { export type SmsMessage = { body: string client_id?: string + contact?: SmsSharedContact | null created_at: DatabaseDateValue delivery_status?: SmsDeliveryStatus direction: SmsDirection @@ -25,6 +39,7 @@ export type SmsMessage = { media_mime: string | null media_waveform: number[] | null message_type: SmsMessageType + share?: EasySharePayload | null read_at: string | null recipient_number: string sender_number: string @@ -32,6 +47,12 @@ export type SmsMessage = { export type SmsOutgoingMessage = | { body: string; messageType: 'text' } + | { + contact: SmsSharedContact + contactId: string + messageType: 'contact' + } + | { body?: string; messageType: 'share'; sharePayload: EasySharePayload } | { mediaDurationMs: number mediaMime: string diff --git a/frontend/src/views/apps/DarkChatApp.vue b/frontend/src/views/apps/DarkChatApp.vue index bd91ad0..65f4301 100644 --- a/frontend/src/views/apps/DarkChatApp.vue +++ b/frontend/src/views/apps/DarkChatApp.vue @@ -51,6 +51,7 @@ import { useRouter } from 'vue-router' import DarkChatVoiceMessage from '@/components/DarkChatVoiceMessage.vue' import FullEmojiPicker from '@/components/FullEmojiPicker.vue' +import SharedContentCard from '@/components/SharedContentCard.vue' import { useAccountStore } from '@/stores/account' import { useDarkChatStore } from '@/stores/darkchat' import { useEasyShareStore } from '@/stores/easyshare' @@ -63,6 +64,7 @@ import type { DarkChatNotificationMode, } from '@/types/darkchat' import type { GifSearchResult } from '@/types/messages' +import type { EasySharePayload } from '@/types/easyshare' import type { MediaType, PhoneMedia } from '@/types/media' import { copyText } from '@/utils/clipboard' import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date' @@ -110,7 +112,8 @@ const identifier = ref('') const pendingIdentifier = ref('') const safetyOpen = ref(false) const draft = ref('') -const queuedShareBody = ref('') +const queuedSharePayload = ref(null) +const shareDraft = ref(null) const aliasDraft = ref('') const contactAliasDraft = ref('') const notificationMode = ref('private') @@ -231,6 +234,7 @@ function preview(conversation: DarkChatConversationSummary): string { if (conversation.lastMessageType === 'gif') return `GIF ยท ${t('gif')}` if (conversation.lastMessageType === 'image') return `๐Ÿ“ท ${t('photo')}` if (conversation.lastMessageType === 'video') return `โ–ถ ${t('video')}` + if (conversation.lastMessageType === 'share') return `๐Ÿ”— ${conversation.lastMessage}` if (conversation.lastMessageType === 'system') return t('securityUpdate') return conversation.lastMessage } @@ -330,9 +334,9 @@ async function openConversation(conversationId: string): Promise { } screen.value = 'thread' resetPanels() - if (queuedShareBody.value) { - draft.value = queuedShareBody.value - queuedShareBody.value = '' + if (queuedSharePayload.value) { + shareDraft.value = queuedSharePayload.value + queuedSharePayload.value = null } await scrollBottom(false) } @@ -344,6 +348,7 @@ function back(): void { } if (screen.value === 'thread') darkchat.closeThread() screen.value = 'inbox' + shareDraft.value = null resetPanels() } @@ -411,19 +416,26 @@ async function confirmStart(): Promise { async function sendText(): Promise { const body = draft.value.trim() - if (!body || sending.value) return + if ((!body && !shareDraft.value) || sending.value) return + const shared = shareDraft.value draft.value = '' + shareDraft.value = null const outgoingReply = replyTo.value?.id replyTo.value = null resetPanels() sending.value = true const response = await darkchat.send({ body, - messageType: 'text', + messageType: shared ? 'share' : 'text', replyToId: outgoingReply, + sharePayload: shared ?? undefined, }) sending.value = false - if (!response.success) showToast(errorText(response.error)) + if (!response.success) { + draft.value = body + shareDraft.value = shared + showToast(errorText(response.error)) + } await scrollBottom() } @@ -513,7 +525,7 @@ async function openEasyShareDraft(): Promise { darkchat.closeThread() screen.value = 'inbox' resetPanels() - queuedShareBody.value = shared.body + queuedSharePayload.value = shared.payload draft.value = '' return true } @@ -523,8 +535,9 @@ async function openEasyShareDraft(): Promise { } screen.value = 'thread' resetPanels() - queuedShareBody.value = '' - draft.value = shared.body + queuedSharePayload.value = null + shareDraft.value = shared.payload + draft.value = '' await scrollBottom(false) return true } @@ -2450,6 +2463,11 @@ onBeforeUnmount(() => { v-else-if="message.messageType === 'voice'" :message="message" /> + {{ message.body }} { @close="emojiOpen = false" @pick="appendEmoji" /> +
+ + +
{ > @@ -1189,6 +1288,10 @@ onBeforeUnmount(() => { ๐Ÿ˜€ {{ phone.t('Apps.messages.emoji') }} + -
+ + + + +

+ {{ phone.t('Apps.messages.noContactsToShare') }} +

+
+