diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index b63af5a..fd03696 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -635,10 +635,21 @@ button { } .darkchat-round--large { width: 43px; height: 43px; } .darkchat-round.active { background: var(--dc-purple); border-color: var(--dc-purple); } +.darkchat-back { + width: 46px; + height: 46px; + border-color: #3a3a3c; + background: linear-gradient(145deg, rgb(44 44 46 / 96%), rgb(28 28 30 / 92%)); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 9%), 0 5px 18px rgb(0 0 0 / 42%); + color: #fff; + line-height: 0; +} +.darkchat-back svg { display: block; margin: 0; } .darkchat-pill { - min-width: 67px; + width: 54px; + min-width: 54px; height: 37px; - padding: 0 15px; + padding: 0 9px; border: 1px solid var(--dc-border); border-radius: 19px; background: var(--dc-surface); @@ -696,7 +707,7 @@ button { .darkchat-inbox__header > :last-child, .darkchat-sheet-page > header > :last-child { justify-self: end; } .darkchat-security-strip { - height: 28px; + height: 32px; margin: 0 15px 4px; padding: 0 10px; display: flex; @@ -706,10 +717,10 @@ button { border-radius: 9px; background: rgb(139 92 246 / 8%); color: #b9a3ff; - font-size: 9px; + font-size: 11px; } .darkchat-security-strip span { flex: 1; } -.darkchat-security-strip button { padding: 0; border: 0; background: transparent; color: #b9a3ff; font-size: 8px; } +.darkchat-security-strip button { padding: 0; border: 0; background: transparent; color: #b9a3ff; font-size: 10px; } .darkchat-conversations { min-height: 0; flex: 1; padding: 3px 12px 76px; overflow-y: auto; } .darkchat-conversation { position: relative; @@ -759,9 +770,9 @@ button { .darkchat-conversation__body { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 3px; } .darkchat-conversation__body > span { display: flex; align-items: center; gap: 4px; } .darkchat-conversation__body strong { min-width: 0; flex: 1; overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } -.darkchat-conversation__body time { color: var(--dc-muted); font-size: 10px; } +.darkchat-conversation__body time { color: var(--dc-muted); font-size: 11px; } .darkchat-conversation__body svg { color: #636366; } -.darkchat-conversation__body small { overflow: hidden; color: var(--dc-muted); font-size: 11px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-conversation__body small { overflow: hidden; color: var(--dc-muted); font-size: 13px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } .darkchat-timer-icon { position: absolute; right: 4px; bottom: 7px; color: #a78bfa; } .darkchat-empty { flex: 1; padding: 15px 40px 80px; display: flex; align-items: center; justify-content: center; flex-direction: column; text-align: center; } .darkchat-empty > span { width: 57px; height: 57px; display: grid; place-items: center; border-radius: 20px; background: var(--dc-surface); color: var(--dc-purple); } @@ -789,7 +800,7 @@ button { background: rgb(28 28 30 / 94%); backdrop-filter: blur(22px); } -.darkchat-inbox__toolbar input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 12px; } +.darkchat-inbox__toolbar input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 14px; } .darkchat-sheet-page { overflow-y: auto; padding-right: 15px; padding-left: 15px; } .darkchat-sheet-page > header { position: sticky; z-index: 4; top: -1px; margin: 0 -15px; } .darkchat-new-hero { padding: 14px 23px 19px; text-align: center; } @@ -840,28 +851,28 @@ button { .darkchat-qr-card > button { grid-column: 1 / -1; height: 35px; display: flex; align-items: center; justify-content: center; gap: 6px; border: 0; border-radius: 10px; background: rgb(139 92 246 / 16%); color: #c4b5fd; font-size: 11px; } .darkchat-thread { display: flex; flex-direction: column; padding-bottom: 79px; } .darkchat-thread--panel { padding-bottom: 342px; } -.darkchat-thread__header { position: relative; flex: none; grid-template-columns: 42px 1fr 42px; } +.darkchat-thread__header { position: relative; flex: none; grid-template-columns: 50px 1fr 50px; } .darkchat-thread__identity { min-width: 0; padding: 0; display: flex; align-items: center; justify-content: center; gap: 8px; border: 0; background: transparent; } .darkchat-thread__identity > span:nth-child(2) { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; } -.darkchat-thread__identity strong { max-width: 142px; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } -.darkchat-thread__identity small { color: var(--dc-muted); font-size: 8px; } +.darkchat-thread__identity strong { max-width: 142px; overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-thread__identity small { color: var(--dc-muted); font-size: 10px; } .darkchat-thread__identity svg { color: var(--dc-muted); } -.darkchat-thread__meta { height: 21px; flex: none; display: flex; align-items: center; justify-content: center; gap: 3px; border-bottom: 1px solid #1b1b1d; color: var(--dc-muted); font-size: 8px; } -.darkchat-thread__messages { min-height: 0; padding: 12px 13px 18px; flex: 1; display: flex; flex-direction: column; align-items: flex-start; gap: 5px; overflow-y: auto; } -.darkchat-day { align-self: center; margin: 4px 0 8px; color: var(--dc-muted); font-size: 9px; font-weight: 600; } -.darkchat-system { max-width: 86%; margin: 7px auto; display: flex; align-items: center; gap: 4px; color: var(--dc-muted); font-size: 8px; text-align: center; } +.darkchat-thread__meta { height: 25px; flex: none; display: flex; align-items: center; justify-content: center; gap: 4px; border-bottom: 1px solid #1b1b1d; color: var(--dc-muted); font-size: 10px; } +.darkchat-thread__messages { min-height: 0; padding: 14px 15px 20px; flex: 1; display: flex; flex-direction: column; align-items: flex-start; gap: 7px; overflow-y: auto; } +.darkchat-day { align-self: center; margin: 5px 0 9px; color: var(--dc-muted); font-size: 11px; font-weight: 600; } +.darkchat-system { max-width: 86%; margin: 8px auto; display: flex; align-items: center; gap: 5px; color: var(--dc-muted); font-size: 10px; text-align: center; } .darkchat-message { position: relative; - max-width: 82%; - padding: 7px 10px 6px; + max-width: 86%; + padding: 9px 12px 8px; display: flex; flex-direction: column; - gap: 3px; + gap: 4px; border: 0; - border-radius: 15px; + border-radius: 18px; background: var(--dc-surface-2); - font-size: 12px; - line-height: 1.3; + font-size: 15px; + line-height: 1.32; text-align: left; overflow-wrap: anywhere; animation: darkchat-message-in .28s cubic-bezier(.32,.72,0,1) both; @@ -871,25 +882,27 @@ button { .darkchat-message--sent::after { content: ""; position: absolute; bottom: 0; width: 9px; height: 11px; } .darkchat-message--received::before { left: -5px; border-bottom-right-radius: 10px; background: var(--dc-surface-2); } .darkchat-message--sent::after { right: -5px; border-bottom-left-radius: 10px; background: #6d28d9; } -.darkchat-message > small { align-self: flex-end; color: rgb(255 255 255 / 57%); font-size: 7px; white-space: nowrap; } +.darkchat-message > small { align-self: flex-end; color: rgb(255 255 255 / 57%); font-size: 9px; white-space: nowrap; } .darkchat-message--received > small { color: var(--dc-muted); } .darkchat-message--failed { outline: 1px solid var(--dc-red); } -.darkchat-message > img { width: min(181px, 62vw); max-height: 175px; margin: -5px -8px 1px; border-radius: 12px; object-fit: cover; } -.darkchat-reply-preview { padding: 4px 6px; display: flex; align-items: center; gap: 4px; overflow: hidden; border-left: 2px solid #c4b5fd; border-radius: 4px; background: rgb(0 0 0 / 18%); color: rgb(255 255 255 / 72%); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-message > img, +.darkchat-message > video { width: min(228px, 74vw); max-height: 240px; margin: -7px -10px 2px; border-radius: 14px; object-fit: cover; } +.darkchat-message > video { background: #000; } +.darkchat-reply-preview { padding: 5px 7px; display: flex; align-items: center; gap: 5px; overflow: hidden; border-left: 2px solid #c4b5fd; border-radius: 5px; background: rgb(0 0 0 / 18%); color: rgb(255 255 255 / 72%); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } .darkchat-reactions { position: absolute; right: 4px; bottom: -15px; z-index: 2; padding: 1px 5px; border: 2px solid #000; border-radius: 9px; background: #343438; font-size: 9px; white-space: nowrap; } .darkchat-message:has(.darkchat-reactions) { margin-bottom: 12px; } .darkchat-replying { position: absolute; z-index: 6; right: 55px; bottom: 72px; left: 55px; min-height: 39px; padding: 5px 8px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--dc-border); border-radius: 11px; background: rgb(28 28 30 / 96%); color: #c4b5fd; } .darkchat-replying > span { min-width: 0; flex: 1; display: flex; flex-direction: column; } -.darkchat-replying small { font-size: 7px; text-transform: uppercase; } -.darkchat-replying strong { overflow: hidden; color: #fff; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.darkchat-replying small { font-size: 9px; text-transform: uppercase; } +.darkchat-replying strong { overflow: hidden; color: #fff; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } .darkchat-replying button { width: 25px; height: 25px; display: grid; place-items: center; border: 0; border-radius: 50%; background: #343438; } .darkchat-composer { position: absolute; z-index: 7; right: 12px; bottom: 28px; left: 12px; display: flex; align-items: flex-end; gap: 8px; } -.darkchat-composer > label { min-height: 41px; padding: 6px 6px 6px 13px; flex: 1; display: flex; align-items: center; gap: 5px; border: 1px solid var(--dc-border); border-radius: 21px; background: var(--dc-surface); } -.darkchat-composer textarea { min-height: 19px; max-height: 70px; min-width: 0; padding: 2px 0; flex: 1; resize: none; border: 0; outline: 0; background: transparent; font-size: 12px; line-height: 17px; } -.darkchat-composer label button { width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: #a78bfa; } +.darkchat-composer > label { min-height: 47px; padding: 7px 7px 7px 14px; flex: 1; display: flex; align-items: center; gap: 6px; border: 1px solid var(--dc-border); border-radius: 24px; background: var(--dc-surface); } +.darkchat-composer textarea { min-height: 21px; max-height: 76px; min-width: 0; padding: 2px 0; flex: 1; resize: none; border: 0; outline: 0; background: transparent; font-size: 14px; line-height: 19px; } +.darkchat-composer label button { width: 32px; height: 32px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: #a78bfa; } .darkchat-actions-bubbles { position: absolute; z-index: 8; bottom: 76px; left: 14px; display: flex; flex-direction: column-reverse; align-items: flex-start; gap: 6px; } -.darkchat-actions-bubbles button { min-height: 32px; padding: 3px 10px 3px 4px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--dc-border); border-radius: 17px; background: var(--dc-surface); font-size: 9px; box-shadow: 0 7px 19px rgb(0 0 0 / 45%); animation: darkchat-bubble-in .23s cubic-bezier(.32,.72,0,1) both; } -.darkchat-actions-bubbles button > span { width: 24px; height: 24px; display: grid; place-items: center; border-radius: 50%; background: rgb(139 92 246 / 18%); color: #c4b5fd; font-size: 15px; } +.darkchat-actions-bubbles button { min-height: 40px; padding: 4px 13px 4px 5px; display: flex; align-items: center; gap: 9px; border: 1px solid var(--dc-border); border-radius: 20px; background: var(--dc-surface); font-size: 12px; box-shadow: 0 7px 19px rgb(0 0 0 / 45%); animation: darkchat-bubble-in .23s cubic-bezier(.32,.72,0,1) both; } +.darkchat-actions-bubbles button > span { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; background: rgb(139 92 246 / 18%); color: #c4b5fd; font-size: 18px; } .darkchat-gif-panel, .darkchat-page .messages-full-emoji-picker { position: absolute; z-index: 8; right: 0; bottom: 0; left: 0; height: 285px; border-top: 1px solid var(--dc-border); background: rgb(20 20 22 / 97%); color: #fff; backdrop-filter: blur(25px); } .darkchat-gif-panel > header { height: 37px; padding: 0 13px; display: flex; align-items: center; justify-content: space-between; } @@ -914,34 +927,40 @@ button { .darkchat-recorder time { font-size: 10px; } .darkchat-recorder > span { height: 26px; min-width: 0; flex: 1; display: flex; align-items: center; justify-content: center; gap: 1px; overflow: hidden; } .darkchat-recorder > span b { width: 2px; flex: none; border-radius: 2px; background: #a78bfa; } -.darkchat-voice { min-width: 177px; display: flex; align-items: center; gap: 7px; } -.darkchat-voice > button { width: 26px; height: 26px; display: grid; place-items: center; padding: 0; flex: none; border: 0; border-radius: 50%; background: rgb(255 255 255 / 17%); } +.darkchat-voice { min-width: 197px; display: flex; align-items: center; gap: 8px; } +.darkchat-voice > button { width: 30px; height: 30px; display: grid; place-items: center; padding: 0; flex: none; border: 0; border-radius: 50%; background: rgb(255 255 255 / 17%); } .darkchat-voice > div { min-width: 0; flex: 1; } -.darkchat-voice__wave { height: 23px; display: flex; align-items: center; gap: 1px; } +.darkchat-voice__wave { height: 27px; display: flex; align-items: center; gap: 1px; } .darkchat-voice__wave i { width: 2px; border-radius: 2px; background: rgb(255 255 255 / 38%); } .darkchat-voice__wave i.active { background: #fff; } -.darkchat-voice > div small { display: block; font-size: 7px; } -.darkchat-voice .darkchat-voice__speed { width: 27px; color: #fff; font-size: 7px; font-weight: 800; } +.darkchat-voice > div small { display: block; font-size: 9px; } +.darkchat-voice .darkchat-voice__speed { width: 30px; color: #fff; font-size: 9px; font-weight: 800; } .darkchat-profile-hero { padding: 15px 0 17px; display: flex; align-items: center; flex-direction: column; } .darkchat-profile-hero .darkchat-avatar { width: 67px; height: 67px; font-size: 30px; box-shadow: 0 13px 32px rgb(91 33 182 / 32%); } .darkchat-profile-hero h2 { margin: 10px 0 3px; font-size: 20px; } -.darkchat-profile-hero button { padding: 4px 8px; display: flex; align-items: center; gap: 5px; border: 0; border-radius: 11px; background: var(--dc-surface); color: #c4b5fd; font-size: 9px; } -.darkchat-profile-hero small { margin-top: 6px; color: var(--dc-muted); font-size: 8px; } +.darkchat-profile-hero button { padding: 5px 9px; display: flex; align-items: center; gap: 5px; border: 0; border-radius: 11px; background: var(--dc-surface); color: #c4b5fd; font-size: 11px; } +.darkchat-profile-hero small { margin-top: 6px; color: var(--dc-muted); font-size: 10px; } .darkchat-settings-group, .darkchat-danger-group { margin: 10px 0; overflow: hidden; border: 1px solid var(--dc-border); border-radius: 13px; background: var(--dc-surface); } +.darkchat-settings-group { overflow: visible; } .darkchat-settings-group label, -.darkchat-danger-group button { width: 100%; min-height: 43px; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; gap: 9px; border: 0; border-bottom: 1px solid var(--dc-border); background: transparent; font-size: 11px; } +.darkchat-settings-group .darkchat-select, +.darkchat-danger-group button { width: 100%; min-height: 50px; padding: 0 13px; display: flex; align-items: center; justify-content: space-between; gap: 9px; border: 0; border-bottom: 1px solid var(--dc-border); background: transparent; font-size: 13px; } .darkchat-settings-group label:last-child, +.darkchat-settings-group .darkchat-select:last-child, .darkchat-danger-group button:last-child { border-bottom: 0; } .darkchat-settings-group label > span, +.darkchat-settings-group .darkchat-select > span, .darkchat-danger-group button { justify-content: flex-start; } -.darkchat-settings-group label > span { display: flex; align-items: center; gap: 8px; } -.darkchat-settings-group label > span svg { color: #a78bfa; } +.darkchat-settings-group label > span, +.darkchat-settings-group .darkchat-select > span { display: flex; align-items: center; gap: 8px; } +.darkchat-settings-group label > span svg, +.darkchat-settings-group .darkchat-select > span svg { color: #a78bfa; } .darkchat-settings-group input[type="checkbox"] { width: 36px; height: 21px; accent-color: var(--dc-purple); } -.darkchat-select select { min-width: 90px; border: 0; outline: 0; background: transparent; color: #a78bfa; font-size: 9px; text-align: right; } +.darkchat-select { position: relative; z-index: 2; } .darkchat-danger-group button { color: var(--dc-red); text-align: left; } -.darkchat-save { padding: 7px; border: 0; background: transparent; color: #a78bfa !important; font-size: 11px; } -.darkchat-privacy-note { margin: 14px 4px 4px; display: flex; align-items: flex-start; gap: 7px; color: var(--dc-muted); font-size: 9px; line-height: 1.4; } +.darkchat-save { padding: 7px; border: 0; background: transparent; color: #a78bfa !important; font-size: 13px; } +.darkchat-privacy-note { margin: 14px 4px 4px; display: flex; align-items: flex-start; gap: 7px; color: var(--dc-muted); font-size: 11px; line-height: 1.45; } .darkchat-privacy-note svg { flex: none; color: #a78bfa; } .darkchat-modal-backdrop { position: absolute; z-index: 40; inset: 0; padding: 44px 18px 27px; display: flex; align-items: center; justify-content: center; background: rgb(0 0 0 / 67%); backdrop-filter: blur(10px); } .darkchat-modal, @@ -950,14 +969,17 @@ button { .darkchat-modal__icon { width: 52px; height: 52px; margin: 0 auto; display: grid; place-items: center; border-radius: 17px; background: rgb(139 92 246 / 16%); color: #a78bfa; } .darkchat-modal__icon--danger { background: rgb(255 69 58 / 14%); color: var(--dc-red); } .darkchat-modal h2 { margin: 11px 0 6px; font-size: 18px; } -.darkchat-modal p { margin: 0 0 10px; color: var(--dc-muted); font-size: 10px; line-height: 1.4; } +.darkchat-modal p { margin: 0 0 10px; color: var(--dc-muted); font-size: 12px; line-height: 1.45; } .darkchat-modal > strong { margin-bottom: 12px; color: #c4b5fd; font-size: 12px; } -.darkchat-modal > button:not(.darkchat-primary):not(.darkchat-danger) { min-height: 35px; border: 0; background: transparent; color: var(--dc-muted); font-size: 11px; } +.darkchat-modal > button:not(.darkchat-primary):not(.darkchat-danger) { min-height: 40px; border: 0; background: transparent; color: var(--dc-muted); font-size: 13px; } .darkchat-modal select, .darkchat-modal textarea { margin-bottom: 8px; padding: 9px; border: 1px solid var(--dc-border); border-radius: 10px; outline: 0; background: #2c2c2e; font-size: 11px; } +.darkchat-report-select { width: 100%; margin-bottom: 8px; } +.darkchat-report-select .darkchat-choice__trigger, +.darkchat-report-select .darkchat-choice__menu { width: 100%; } .darkchat-modal textarea { min-height: 65px; resize: none; } .darkchat-message-menu { padding: 7px; } -.darkchat-message-menu > button { min-height: 39px; padding: 0 11px; display: flex; align-items: center; gap: 9px; border: 0; border-bottom: 1px solid var(--dc-border); background: transparent; font-size: 11px; text-align: left; } +.darkchat-message-menu > button { min-height: 46px; padding: 0 12px; display: flex; align-items: center; gap: 10px; border: 0; border-bottom: 1px solid var(--dc-border); background: transparent; font-size: 13px; text-align: left; } .darkchat-message-menu > button:last-child { justify-content: center; border-bottom: 0; color: var(--dc-muted); } .darkchat-message-menu > button.danger { color: var(--dc-red); } .darkchat-reaction-row { margin: -30px 4px 8px; padding: 8px 5px; display: flex; justify-content: space-around; border: 1px solid var(--dc-border); border-radius: 20px; background: #2c2c2e; box-shadow: 0 7px 20px rgb(0 0 0 / 35%); } diff --git a/frontend/src/components/DarkChatSelect.vue b/frontend/src/components/DarkChatSelect.vue new file mode 100644 index 0000000..804ae57 --- /dev/null +++ b/frontend/src/components/DarkChatSelect.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/frontend/src/config/ifruit.ts b/frontend/src/config/ifruit.ts deleted file mode 100644 index 34c54de..0000000 --- a/frontend/src/config/ifruit.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const IFRUIT_AUTH_INPUT_COLORS = { - outlineLabelBgIos: 'bg-black', -} diff --git a/frontend/src/stores/darkchat.test.ts b/frontend/src/stores/darkchat.test.ts index 811ab04..2cedfd7 100644 --- a/frontend/src/stores/darkchat.test.ts +++ b/frontend/src/stores/darkchat.test.ts @@ -67,6 +67,33 @@ describe('darkchat store', () => { expect(store.messages[0].deliveryStatus).toBe('failed') }) + it('uses a local media preview without sending that URL to the server', async () => { + mockNuiCall + .mockResolvedValueOnce({ data: { conversation, messages: [] }, success: true }) + .mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true }) + .mockResolvedValueOnce({ data: { ...message('media-id'), messageType: 'image' }, success: true }) + .mockResolvedValueOnce({ data: { contacts: [], conversations: [], profile: null }, success: true }) + + const store = useDarkChatStore() + await store.openThread(conversation.id) + const sending = store.send({ + mediaAssetId: '17', + mediaPreviewUrl: 'https://media.example/photo.jpg', + messageType: 'image', + }) + + expect(store.messages[0]).toMatchObject({ + mediaPayload: 'https://media.example/photo.jpg', + messageType: 'image', + }) + expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'darkchat:send', { + conversationId: conversation.id, + mediaAssetId: '17', + messageType: 'image', + }) + await sending + }) + it('loads protected voice data once', async () => { mockNuiCall.mockResolvedValueOnce({ data: { mime: 'audio/webm;codecs=opus', payload: 'ZmFrZQ==' }, diff --git a/frontend/src/stores/darkchat.ts b/frontend/src/stores/darkchat.ts index 088d133..47a3b9b 100644 --- a/frontend/src/stores/darkchat.ts +++ b/frontend/src/stores/darkchat.ts @@ -76,6 +76,7 @@ export const useDarkChatStore = defineStore('darkchat', () => { async function send(outgoing: DarkChatOutgoing): Promise> { if (!activeConversation.value) return { success: false, error: 'invalid_conversation' } const clientId = `dark-pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const { mediaPreviewUrl, ...request } = outgoing const optimistic: DarkChatMessage = { body: outgoing.body ?? '', clientId, @@ -86,6 +87,10 @@ export const useDarkChatStore = defineStore('darkchat', () => { id: clientId, mediaDurationMs: outgoing.mediaDurationMs, mediaMime: outgoing.mediaMime, + mediaPayload: + outgoing.messageType === 'image' || outgoing.messageType === 'video' + ? mediaPreviewUrl + : outgoing.mediaPayload, mediaWaveform: outgoing.mediaWaveform, messageType: outgoing.messageType, reactions: {}, @@ -96,7 +101,7 @@ export const useDarkChatStore = defineStore('darkchat', () => { mediaSources.value[clientId] = `data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}` } const response = await nuiCall('darkchat:send', { - ...outgoing, + ...request, conversationId: activeConversation.value.id, }) const index = messages.value.findIndex((message) => message.clientId === clientId) diff --git a/frontend/src/stores/messageMedia.test.ts b/frontend/src/stores/messageMedia.test.ts new file mode 100644 index 0000000..d972bd5 --- /dev/null +++ b/frontend/src/stores/messageMedia.test.ts @@ -0,0 +1,34 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useMessageMediaStore } from '@/stores/messageMedia' +import type { PhoneMedia } from '@/types/media' + +const photo: PhoneMedia = { + createdAt: 1_786_035_600, + id: 17, + mediaType: 'photo', + url: 'https://media.example/photo.jpg', +} + +describe('message media handoff', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('returns captured media to the requesting DarkChat conversation', () => { + const store = useMessageMediaStore() + store.begin('darkchat:conversation-id', 'photo', '/apps/darkchat') + + expect(store.complete(photo)).toBe('/apps/darkchat') + expect(store.consume('darkchat:another-conversation')).toBeNull() + expect(store.consume('darkchat:conversation-id')).toEqual(photo) + }) + + it('keeps the request active when the selected media type does not match', () => { + const store = useMessageMediaStore() + store.begin('4205550196', 'video') + + expect(store.complete(photo)).toBeNull() + expect(store.request).toMatchObject({ mediaType: 'video', target: '4205550196' }) + expect(store.cancel()).toBe('/apps/messages') + }) +}) diff --git a/frontend/src/stores/messageMedia.ts b/frontend/src/stores/messageMedia.ts index 3ef4661..083a461 100644 --- a/frontend/src/stores/messageMedia.ts +++ b/frontend/src/stores/messageMedia.ts @@ -4,7 +4,8 @@ import type { MediaType, PhoneMedia } from '@/types/media' type MessageMediaRequest = { mediaType: MediaType - phoneNumber: string + returnPath: string + target: string } type MessageMediaResult = MessageMediaRequest & { @@ -17,20 +18,28 @@ export const useMessageMediaStore = defineStore('message-media', { result: null as MessageMediaResult | null, }), actions: { - begin(phoneNumber: string, mediaType: MediaType): void { - this.request = { mediaType, phoneNumber } + begin( + target: string, + mediaType: MediaType, + returnPath = '/apps/messages', + ): void { + this.request = { mediaType, returnPath, target } this.result = null }, - cancel(): void { + cancel(): string { + const returnPath = this.request?.returnPath ?? '/apps/messages' this.request = null + return returnPath }, - complete(media: PhoneMedia): void { - if (!this.request || this.request.mediaType !== media.mediaType) return + complete(media: PhoneMedia): string | null { + if (!this.request || this.request.mediaType !== media.mediaType) return null + const returnPath = this.request.returnPath this.result = { ...this.request, media } this.request = null + return returnPath }, - consume(phoneNumber: string): PhoneMedia | null { - if (!this.result || this.result.phoneNumber !== phoneNumber) return null + consume(target: string): PhoneMedia | null { + if (!this.result || this.result.target !== target) return null const media = this.result.media this.result = null return media diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 1f27f1e..ba6789f 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -36,7 +36,7 @@ const defaultLocales: LocaleTree = { noChats: 'No Private Chats', noChatsBody: 'Connect with a Dark-ID or invitation code. There is no public search.', newChat: 'New Chat', connectPrivately: 'Connect privately', newChatBody: 'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.', darkIdOrInvite: 'Dark-ID or invitation code', continue: 'Continue', contacts: 'DarkChat Contacts', shareIdentity: 'Tap to share your private identity', message: 'Dark message', activeNow: 'Activity shared', encryptedSession: 'Private session', serverPrivate: 'Private server-stored conversation', - emoji: 'Emoji', gif: 'GIF', gifs: 'GIFs', searchGifs: 'Search GIFs', loadMore: 'Load More', photosLater: 'Photos · coming later', videosLater: 'Videos · coming later', + emoji: 'Emoji', gif: 'GIF', gifs: 'GIFs', photo: 'Photo', video: 'Video', attachPhoto: 'Attach Photo', takePhoto: 'Take Photo', attachGif: 'Attach GIF', attachVideo: 'Attach Video', searchGifs: 'Search GIFs', loadMore: 'Load More', voiceMessage: 'Voice message', sending: 'Sending', failed: 'Not delivered', delivered: 'Delivered', read: 'Read', replying: 'Replying to', messageDeleted: 'Message deleted', securityUpdate: 'Security settings updated', timerChanged: 'Disappearing messages: {timer}', timerOff: 'Off', timerAfterRead: 'After reading', timerMinute: '1 minute', timerFiveMinutes: '5 minutes', timerHour: '1 hour', timerDay: '24 hours', timerWeek: '7 days', @@ -47,7 +47,7 @@ const defaultLocales: LocaleTree = { reportUser: 'Report User', reportSpam: 'Spam', reportHarassment: 'Harassment', reportThreats: 'Threats', reportIllegal: 'Illegal content', reportOther: 'Other', reportDetails: 'Optional details', submitReport: 'Submit Report', reported: 'Report submitted', microphoneUnavailable: 'The microphone is unavailable.', recordingTooLarge: 'The voice message is too large.', errors: { - not_authenticated: 'Sign in to your iFruit account first.', invalid_dark_id: 'Enter a valid Dark-ID or invitation code.', profile_not_found: 'This private identity was not found.', self_chat: 'You cannot message your own identity.', conversation_not_found: 'This conversation is unavailable.', blocked: 'Messages are blocked in this conversation.', invalid_message: 'Enter a valid message.', invalid_gif: 'This GIF is invalid.', invalid_voice: 'This voice message is invalid.', invalid_profile: 'Check your alias and privacy settings.', rate_limited: 'Too many requests. Try again shortly.', gif_provider_unconfigured: 'GIF search is not configured.', gif_provider_unauthorized: 'The GIF provider key is invalid.', gif_provider_rate_limited: 'GIF search is busy. Try again shortly.', gif_provider_failed: 'GIFs are temporarily unavailable.', default: 'DarkChat could not complete the request.', + not_authenticated: 'Sign in to your iFruit account first.', invalid_dark_id: 'Enter a valid Dark-ID or invitation code.', profile_not_found: 'This private identity was not found.', self_chat: 'You cannot message your own identity.', conversation_not_found: 'This conversation is unavailable.', blocked: 'Messages are blocked in this conversation.', invalid_message: 'Enter a valid message.', invalid_gif: 'This GIF is invalid.', invalid_voice: 'This voice message is invalid.', invalid_attachment: 'This photo or video is unavailable.', invalid_profile: 'Check your alias and privacy settings.', rate_limited: 'Too many requests. Try again shortly.', gif_provider_unconfigured: 'GIF search is not configured.', gif_provider_unauthorized: 'The GIF provider key is invalid.', gif_provider_rate_limited: 'GIF search is busy. Try again shortly.', gif_provider_failed: 'GIFs are temporarily unavailable.', default: 'DarkChat could not complete the request.', }, }, messages: { diff --git a/frontend/src/types/darkchat.ts b/frontend/src/types/darkchat.ts index 71040f4..05dcf0a 100644 --- a/frontend/src/types/darkchat.ts +++ b/frontend/src/types/darkchat.ts @@ -1,6 +1,13 @@ import type { DatabaseDateValue } from '@/utils/date' -export type DarkChatMessageType = 'text' | 'emoji' | 'gif' | 'voice' | 'system' +export type DarkChatMessageType = + | 'text' + | 'emoji' + | 'gif' + | 'voice' + | 'image' + | 'video' + | 'system' export type DarkChatNotificationMode = 'full' | 'private' | 'hidden' export type DarkChatIdentity = { @@ -81,8 +88,10 @@ export type DarkChatThread = { export type DarkChatOutgoing = { body?: string - messageType: 'text' | 'emoji' | 'gif' | 'voice' + messageType: 'text' | 'emoji' | 'gif' | 'voice' | 'image' | 'video' + mediaAssetId?: string mediaPayload?: string + mediaPreviewUrl?: string mediaMime?: string mediaDurationMs?: number mediaWaveform?: number[] diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts new file mode 100644 index 0000000..c577c29 --- /dev/null +++ b/frontend/src/utils/clipboard.ts @@ -0,0 +1,23 @@ +export function copyText(value: string): boolean { + const activeElement = + document.activeElement instanceof HTMLElement + ? document.activeElement + : undefined + const textarea = document.createElement('textarea') + + textarea.value = value + textarea.readOnly = true + textarea.style.position = 'fixed' + textarea.style.left = '-9999px' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.select() + textarea.setSelectionRange(0, value.length) + + try { + return document.execCommand('copy') + } finally { + textarea.remove() + activeElement?.focus({ preventScroll: true }) + } +} diff --git a/frontend/src/views/apps/CameraApp.vue b/frontend/src/views/apps/CameraApp.vue index a9ca7d2..eae808e 100644 --- a/frontend/src/views/apps/CameraApp.vue +++ b/frontend/src/views/apps/CameraApp.vue @@ -315,8 +315,8 @@ function onMessage(event: MessageEvent): void { latestMedia.value = result.media updateCapture(result.correlationId, { status: 'success' }) if (requestedMessageMedia.value === result.media.mediaType) { - messageMedia.complete(result.media) - void router.replace('/apps/messages') + const returnPath = messageMedia.complete(result.media) + if (returnPath) void router.replace(returnPath) return } showCameraNotice(phone.t('Apps.camera.saved')) diff --git a/frontend/src/views/apps/DarkChatApp.vue b/frontend/src/views/apps/DarkChatApp.vue index 01b7ff5..a5f786b 100644 --- a/frontend/src/views/apps/DarkChatApp.vue +++ b/frontend/src/views/apps/DarkChatApp.vue @@ -1,14 +1,16 @@ - + +
diff --git a/sky_phone/source/server/darkchat.lua b/sky_phone/source/server/darkchat.lua index 87368c0..fc40dc6 100644 --- a/sky_phone/source/server/darkchat.lua +++ b/sky_phone/source/server/darkchat.lua @@ -490,6 +490,9 @@ Bridge.Callbacks.Register("sky_phone:darkchat:send", function(source, data) if not SkyPhone.AllowOperation(source, "darkchat_send", Config.DarkChat.SendsPerMinute, 60) then return { success = false, error = "rate_limited" } end + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end local profile, _, error_response = require_profile(source) if not profile then return error_response @@ -527,6 +530,14 @@ Bridge.Callbacks.Register("sky_phone:darkchat:send", function(source, data) media_mime = voice.mime media_duration = voice.duration media_waveform = voice.waveform + elseif message_type == "image" or message_type == "video" then + local media_type = message_type == "image" and "photo" or "video" + local media_url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, data.mediaAssetId, media_type) + if not media_url then + return { success = false, error = media_error } + end + media_payload = media_url + media_mime = message_type == "image" and "image/jpeg" or "video/mp4" else return { success = false, error = "invalid_message" } end diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index b2d437a..fcf702c 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -746,7 +746,7 @@ local schema = { { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, { name = "conversation_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, { name = "sender_profile_id", type = "BIGINT UNSIGNED NULL" }, - { name = "message_type", type = "ENUM('text', 'emoji', 'gif', 'voice', 'system') NOT NULL DEFAULT 'text'" }, + { name = "message_type", type = "ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'system') NOT NULL DEFAULT 'text'" }, { name = "body", type = "TEXT NOT NULL" }, { name = "media_payload", type = "LONGTEXT NULL" }, { name = "media_mime", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" }, @@ -819,6 +819,10 @@ Bridge.Database.Query([[ ALTER TABLE `sky_phone_sms_messages` MODIFY COLUMN `message_type` ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text' ]], {}) +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.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 }) diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua index ffdbb05..7f9341a 100644 --- a/sky_phone/source/server/media.lua +++ b/sky_phone/source/server/media.lua @@ -34,7 +34,7 @@ local function http_request(url, method, body, headers, timeout_ms) settled = true request:resolve({ status = 0, body = "request_timeout" }) end) - return Await(request) + return Citizen.Await(request) end local function decode_response(response) @@ -136,6 +136,45 @@ local function owners_match(left, right) return left.imei == right.imei and left.account_id == right.account_id end +function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type) + local id = tonumber(media_id) + if not id or id < 1 or id ~= math.floor(id) + or (media_type ~= "photo" and media_type ~= "video") + then + return nil, "invalid_attachment" + end + local owner, error_response = session_owner(source) + if not owner then + return nil, error_response.error + end + local condition, owner_params = owner_condition(owner) + local params = { id } + for _, value in ipairs(owner_params) do + params[#params + 1] = value + 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] + if not media or media.media_type ~= media_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 %s media %s from source %s.", + media_type, + tostring(media_id), + tostring(source) + ) + return nil, "invalid_attachment" + end + return media.url +end + local function upload_result(source, correlation_id, success, error_code, media) TriggerClientEvent("sky_phone:media:upload-result", source, { correlationId = correlation_id, @@ -268,7 +307,7 @@ local function await_giphy_http(url) status = status, }) end, "GET", "", {}) - return Await(request) + return Citizen.Await(request) end local function parse_giphy_json(value)