mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-31 02:08:58 +00:00
ENH - add DarkChat media attachments
This commit is contained in:
@@ -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%); }
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
export type DarkChatSelectOption = {
|
||||
label: string
|
||||
value: number | string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
modelValue: number | string
|
||||
options: DarkChatSelectOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number | string]
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const opened = ref(false)
|
||||
const selectedOption = computed(
|
||||
() =>
|
||||
props.options.find((option) => option.value === props.modelValue) ??
|
||||
props.options[0],
|
||||
)
|
||||
|
||||
function selectOption(option: DarkChatSelectOption): void {
|
||||
emit('update:modelValue', option.value)
|
||||
opened.value = false
|
||||
}
|
||||
|
||||
function closeFromOutside(event: PointerEvent): void {
|
||||
if (!root.value?.contains(event.target as Node)) opened.value = false
|
||||
}
|
||||
|
||||
function closeFromEscape(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') opened.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', closeFromOutside)
|
||||
document.addEventListener('keydown', closeFromEscape)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', closeFromOutside)
|
||||
document.removeEventListener('keydown', closeFromEscape)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="darkchat-choice">
|
||||
<button
|
||||
type="button"
|
||||
class="darkchat-choice__trigger"
|
||||
role="combobox"
|
||||
:aria-label="label"
|
||||
:aria-expanded="opened"
|
||||
aria-haspopup="listbox"
|
||||
@click="opened = !opened"
|
||||
>
|
||||
<span>{{ selectedOption?.label }}</span>
|
||||
<ChevronDown :size="14" :class="{ open: opened }" />
|
||||
</button>
|
||||
|
||||
<Transition name="darkchat-choice">
|
||||
<div v-if="opened" class="darkchat-choice__menu" role="listbox">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="option.value === modelValue"
|
||||
:class="{ selected: option.value === modelValue }"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<Check v-if="option.value === modelValue" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.darkchat-choice { position: relative; min-width: 0; }
|
||||
.darkchat-choice__trigger {
|
||||
width: 145px;
|
||||
height: 35px;
|
||||
padding: 0 10px 0 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(139 92 246 / 28%);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(145deg, rgb(44 44 46 / 96%), rgb(28 28 30 / 96%));
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 7%);
|
||||
color: #c4b5fd;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
.darkchat-choice__trigger span,
|
||||
.darkchat-choice__menu span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.darkchat-choice__trigger svg { flex: none; transition: transform .18s ease; }
|
||||
.darkchat-choice__trigger svg.open { transform: rotate(180deg); }
|
||||
.darkchat-choice__menu {
|
||||
position: absolute;
|
||||
z-index: 80;
|
||||
top: calc(100% + 5px);
|
||||
right: 0;
|
||||
width: 195px;
|
||||
padding: 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(139 92 246 / 28%);
|
||||
border-radius: 13px;
|
||||
background: rgb(28 28 30 / 98%);
|
||||
box-shadow: 0 14px 38px rgb(0 0 0 / 65%);
|
||||
backdrop-filter: blur(24px);
|
||||
}
|
||||
.darkchat-choice__menu button {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 7px 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 7px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #f5f5f7;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
.darkchat-choice__menu button.selected { background: rgb(139 92 246 / 18%); color: #c4b5fd; }
|
||||
.darkchat-choice__menu button svg { flex: none; color: #a78bfa; }
|
||||
.darkchat-choice-enter-active,
|
||||
.darkchat-choice-leave-active { transition: opacity .16s ease, transform .16s ease; transform-origin: top right; }
|
||||
.darkchat-choice-enter-from,
|
||||
.darkchat-choice-leave-to { opacity: 0; transform: translateY(-4px) scale(.97); }
|
||||
</style>
|
||||
@@ -1,3 +0,0 @@
|
||||
export const IFRUIT_AUTH_INPUT_COLORS = {
|
||||
outlineLabelBgIos: 'bg-black',
|
||||
}
|
||||
@@ -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==' },
|
||||
|
||||
@@ -76,6 +76,7 @@ export const useDarkChatStore = defineStore('darkchat', () => {
|
||||
async function send(outgoing: DarkChatOutgoing): Promise<NuiResponse<DarkChatMessage>> {
|
||||
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<DarkChatMessage>('darkchat:send', {
|
||||
...outgoing,
|
||||
...request,
|
||||
conversationId: activeConversation.value.id,
|
||||
})
|
||||
const index = messages.value.findIndex((message) => message.clientId === clientId)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUpCircle,
|
||||
Bell,
|
||||
BellOff,
|
||||
Camera,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Copy,
|
||||
ImagePlay,
|
||||
Images,
|
||||
LockKeyhole,
|
||||
MessageCirclePlus,
|
||||
Mic,
|
||||
@@ -21,18 +23,26 @@ import {
|
||||
Trash2,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Video,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import DarkChatVoiceMessage from '@/components/DarkChatVoiceMessage.vue'
|
||||
import DarkChatSelect, {
|
||||
type DarkChatSelectOption,
|
||||
} from '@/components/DarkChatSelect.vue'
|
||||
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { DarkChatConversationSummary, DarkChatMessage, DarkChatNotificationMode } from '@/types/darkchat'
|
||||
import type { GifSearchResult } from '@/types/messages'
|
||||
import type { MediaType, PhoneMedia } from '@/types/media'
|
||||
import { copyText } from '@/utils/clipboard'
|
||||
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
|
||||
|
||||
const VOICE_MAX_DURATION_MS = 60_000
|
||||
@@ -42,7 +52,9 @@ const WAVEFORM_SAMPLES = 48
|
||||
const account = useAccountStore()
|
||||
const darkchat = useDarkChatStore()
|
||||
const sms = useMessagesStore()
|
||||
const messageMedia = useMessageMediaStore()
|
||||
const phone = usePhoneStore()
|
||||
const router = useRouter()
|
||||
const screen = ref<'inbox' | 'new' | 'thread' | 'contact' | 'profile'>('inbox')
|
||||
const search = ref('')
|
||||
const identifier = ref('')
|
||||
@@ -124,6 +136,8 @@ function preview(conversation: DarkChatConversationSummary): string {
|
||||
if (conversation.lastMessage === 'message_deleted') return t('messageDeleted')
|
||||
if (conversation.lastMessageType === 'voice') return `🎙 ${t('voiceMessage')}`
|
||||
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 === 'system') return t('securityUpdate')
|
||||
return conversation.lastMessage
|
||||
}
|
||||
@@ -157,6 +171,22 @@ function timerLabel(seconds: number): string {
|
||||
return t(keys[seconds] ?? 'timerOff')
|
||||
}
|
||||
|
||||
const disappearingOptions = computed<DarkChatSelectOption[]>(() =>
|
||||
timerOptions.map((value) => ({ label: timerLabel(value), value })),
|
||||
)
|
||||
const notificationOptions = computed<DarkChatSelectOption[]>(() => [
|
||||
{ label: t('notificationFull'), value: 'full' },
|
||||
{ label: t('notificationPrivate'), value: 'private' },
|
||||
{ label: t('notificationHidden'), value: 'hidden' },
|
||||
])
|
||||
const reportOptions = computed<DarkChatSelectOption[]>(() => [
|
||||
{ label: t('reportSpam'), value: 'spam' },
|
||||
{ label: t('reportHarassment'), value: 'harassment' },
|
||||
{ label: t('reportThreats'), value: 'threats' },
|
||||
{ label: t('reportIllegal'), value: 'illegal' },
|
||||
{ label: t('reportOther'), value: 'other' },
|
||||
])
|
||||
|
||||
function systemText(body: string): string {
|
||||
if (body === 'message_deleted') return t('messageDeleted')
|
||||
if (body.startsWith('timer_changed:')) return t('timerChanged', { timer: timerLabel(Number(body.split(':')[1])) })
|
||||
@@ -266,10 +296,37 @@ async function sendGif(gif: GifSearchResult): Promise<void> {
|
||||
await scrollBottom()
|
||||
}
|
||||
|
||||
async function copyMessage(message: DarkChatMessage): Promise<void> {
|
||||
await navigator.clipboard.writeText(message.body)
|
||||
function openMediaApp(app: 'camera' | 'photos', mediaType: MediaType): void {
|
||||
if (!active.value) {
|
||||
console.error('[DarkChat] Cannot attach media without an active conversation.')
|
||||
return
|
||||
}
|
||||
attachmentOpen.value = false
|
||||
emojiOpen.value = false
|
||||
gifOpen.value = false
|
||||
messageMedia.begin(`darkchat:${active.value.id}`, mediaType, '/apps/darkchat')
|
||||
void router.push({
|
||||
path: `/apps/${app}`,
|
||||
query: { messageAttachment: mediaType },
|
||||
})
|
||||
}
|
||||
|
||||
async function sendAttachment(media: PhoneMedia): Promise<void> {
|
||||
if (!active.value || sending.value) return
|
||||
sending.value = true
|
||||
const response = await darkchat.send({
|
||||
mediaAssetId: import.meta.env.DEV ? media.url : String(media.id),
|
||||
mediaPreviewUrl: media.url,
|
||||
messageType: media.mediaType === 'photo' ? 'image' : 'video',
|
||||
})
|
||||
sending.value = false
|
||||
if (!response.success) showToast(errorText(response.error))
|
||||
await scrollBottom()
|
||||
}
|
||||
|
||||
function copyMessage(message: DarkChatMessage): void {
|
||||
selectedMessage.value = null
|
||||
showToast(t('copied'))
|
||||
showToast(copyText(message.body) ? t('copied') : errorText())
|
||||
}
|
||||
|
||||
async function react(message: DarkChatMessage, reaction: string): Promise<void> {
|
||||
@@ -381,9 +438,25 @@ async function saveProfile(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyIdentity(value: string): Promise<void> {
|
||||
await navigator.clipboard.writeText(value)
|
||||
showToast(t('copied'))
|
||||
function selectDisappearing(value: number | string): void {
|
||||
if (!active.value) {
|
||||
console.error('[DarkChat] Cannot update the timer without an active conversation.')
|
||||
return
|
||||
}
|
||||
active.value.disappearingSeconds = Number(value)
|
||||
void updateConversation()
|
||||
}
|
||||
|
||||
function selectNotificationMode(value: number | string): void {
|
||||
notificationMode.value = value as DarkChatNotificationMode
|
||||
}
|
||||
|
||||
function selectReportReason(value: number | string): void {
|
||||
reportReason.value = String(value)
|
||||
}
|
||||
|
||||
function copyIdentity(value: string): void {
|
||||
showToast(copyText(value) ? t('copied') : errorText())
|
||||
}
|
||||
|
||||
function recordingMime(): string | null {
|
||||
@@ -526,8 +599,12 @@ function recordingTime(): string {
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (signedIn.value) void darkchat.bootstrap()
|
||||
onMounted(async () => {
|
||||
if (signedIn.value) await darkchat.bootstrap()
|
||||
if (!active.value) return
|
||||
screen.value = 'thread'
|
||||
const media = messageMedia.consume(`darkchat:${active.value.id}`)
|
||||
if (media) await sendAttachment(media)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -599,7 +676,7 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
|
||||
<section v-else-if="screen === 'new'" class="darkchat-sheet-page">
|
||||
<header><button type="button" class="darkchat-round" @click="back"><ArrowLeft :size="19" /></button><strong>{{ t('newChat') }}</strong><i /></header>
|
||||
<header><button type="button" class="darkchat-round darkchat-back" :aria-label="phone.t('Common.back')" @click="back"><ChevronLeft :size="28" :stroke-width="2.35" /></button><strong>{{ t('newChat') }}</strong><i /></header>
|
||||
<div class="darkchat-new-hero">
|
||||
<span><QrCode :size="28" /></span>
|
||||
<h2>{{ t('connectPrivately') }}</h2>
|
||||
@@ -621,7 +698,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<section v-else-if="screen === 'thread' && active" class="darkchat-thread" :class="{ 'darkchat-thread--panel': attachmentPanelOpen }">
|
||||
<header class="darkchat-thread__header">
|
||||
<button type="button" class="darkchat-round" @click="back"><ArrowLeft :size="19" /></button>
|
||||
<button type="button" class="darkchat-round darkchat-back" :aria-label="phone.t('Common.back')" @click="back"><ChevronLeft :size="28" :stroke-width="2.35" /></button>
|
||||
<button type="button" class="darkchat-thread__identity" @click="openContact">
|
||||
<span class="darkchat-avatar darkchat-avatar--header" :style="{ background: avatarGradient(active.peer.avatarSeed) }">{{ avatarGlyph(active.peer.avatarSeed) }}</span>
|
||||
<span><strong>{{ active.peer.alias }}</strong><small>{{ active.peer.activityVisible ? t('activeNow') : t('encryptedSession') }}</small></span>
|
||||
@@ -641,7 +718,8 @@ onBeforeUnmount(() => {
|
||||
@click="selectedMessage = message"
|
||||
>
|
||||
<span v-if="message.replyBody" class="darkchat-reply-preview"><Reply :size="10" />{{ message.replyBody }}</span>
|
||||
<img v-if="message.messageType === 'gif'" :src="message.mediaPayload || undefined" alt="GIF" />
|
||||
<img v-if="message.messageType === 'gif' || message.messageType === 'image'" :src="message.mediaPayload || undefined" :alt="message.messageType === 'gif' ? 'GIF' : t('photo')" />
|
||||
<video v-else-if="message.messageType === 'video'" :src="message.mediaPayload || undefined" controls playsinline preload="metadata" />
|
||||
<DarkChatVoiceMessage v-else-if="message.messageType === 'voice'" :message="message" />
|
||||
<span v-else>{{ message.body }}</span>
|
||||
<span v-if="Object.keys(message.reactions).length" class="darkchat-reactions">{{ Object.values(message.reactions).join(' ') }}</span>
|
||||
@@ -652,10 +730,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div v-if="replyTo" class="darkchat-replying"><Reply :size="14" /><span><small>{{ t('replying') }}</small><strong>{{ replyTo.body || t(replyTo.messageType) }}</strong></span><button type="button" @click="replyTo = null"><X :size="15" /></button></div>
|
||||
<div v-if="attachmentOpen" class="darkchat-actions-bubbles">
|
||||
<button type="button" @click="openMediaApp('photos', 'photo')"><span><Images :size="18" /></span>{{ t('attachPhoto') }}</button>
|
||||
<button type="button" @click="openMediaApp('camera', 'photo')"><span><Camera :size="18" /></span>{{ t('takePhoto') }}</button>
|
||||
<button type="button" @click="attachmentOpen = false; emojiOpen = true"><span>😀</span>{{ t('emoji') }}</button>
|
||||
<button type="button" @click="attachmentOpen = false; gifOpen = true; loadGifs(true)"><span><ImagePlay :size="17" /></span>{{ t('gif') }}</button>
|
||||
<button type="button" disabled><span>📷</span>{{ t('photosLater') }}</button>
|
||||
<button type="button" disabled><span>🎥</span>{{ t('videosLater') }}</button>
|
||||
<button type="button" @click="attachmentOpen = false; gifOpen = true; loadGifs(true)"><span><ImagePlay :size="18" /></span>{{ t('attachGif') }}</button>
|
||||
<button type="button" @click="openMediaApp('photos', 'video')"><span><Video :size="18" /></span>{{ t('attachVideo') }}</button>
|
||||
</div>
|
||||
<div v-if="gifOpen" class="darkchat-gif-panel">
|
||||
<header><strong>{{ t('gifs') }}</strong><button type="button" @click="gifOpen = false">{{ phone.t('Common.done') }}</button></header>
|
||||
@@ -677,7 +756,7 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
|
||||
<section v-else-if="screen === 'contact' && active" class="darkchat-sheet-page darkchat-contact-settings">
|
||||
<header><button type="button" class="darkchat-round" @click="back"><ArrowLeft :size="19" /></button><strong>{{ t('contactSecurity') }}</strong><i /></header>
|
||||
<header><button type="button" class="darkchat-round darkchat-back" :aria-label="phone.t('Common.back')" @click="back"><ChevronLeft :size="28" :stroke-width="2.35" /></button><strong>{{ t('contactSecurity') }}</strong><i /></header>
|
||||
<div class="darkchat-profile-hero">
|
||||
<span class="darkchat-avatar" :style="{ background: avatarGradient(active.peer.avatarSeed) }">{{ avatarGlyph(active.peer.avatarSeed) }}</span>
|
||||
<h2>{{ active.peer.alias }}</h2><button type="button" @click="copyIdentity(active.peer.darkId)">{{ active.peer.darkId }} <Copy :size="12" /></button>
|
||||
@@ -686,7 +765,7 @@ onBeforeUnmount(() => {
|
||||
<div class="darkchat-settings-group">
|
||||
<label><span><Bell :size="17" />{{ t('notifications') }}</span><input v-model="active.notificationsEnabled" type="checkbox" @change="updateConversation" /></label>
|
||||
<label><span><Check :size="17" />{{ t('readReceipts') }}</span><input v-model="active.readReceipts" type="checkbox" @change="updateConversation" /></label>
|
||||
<label class="darkchat-select"><span><Clock3 :size="17" />{{ t('disappearing') }}</span><select v-model.number="active.disappearingSeconds" @change="updateConversation"><option v-for="timer in timerOptions" :key="timer" :value="timer">{{ timerLabel(timer) }}</option></select></label>
|
||||
<div class="darkchat-select"><span><Clock3 :size="17" />{{ t('disappearing') }}</span><DarkChatSelect :model-value="active.disappearingSeconds" :options="disappearingOptions" :label="t('disappearing')" @update:model-value="selectDisappearing" /></div>
|
||||
</div>
|
||||
<label class="darkchat-input"><span>{{ t('contactAlias') }}</span><input v-model="contactAliasDraft" maxlength="32" /></label>
|
||||
<button type="button" class="darkchat-primary" @click="saveContact"><UserPlus :size="16" />{{ active.peer.isContact ? t('saveContact') : t('addContact') }}</button>
|
||||
@@ -699,14 +778,14 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
|
||||
<section v-else-if="screen === 'profile'" class="darkchat-sheet-page darkchat-profile-settings">
|
||||
<header><button type="button" class="darkchat-round" @click="back"><ArrowLeft :size="19" /></button><strong>{{ t('myIdentity') }}</strong><button type="button" class="darkchat-save" @click="saveProfile">{{ phone.t('Common.done') }}</button></header>
|
||||
<header><button type="button" class="darkchat-round darkchat-back" :aria-label="phone.t('Common.back')" @click="back"><ChevronLeft :size="28" :stroke-width="2.35" /></button><strong>{{ t('myIdentity') }}</strong><button type="button" class="darkchat-save" @click="saveProfile">{{ phone.t('Common.done') }}</button></header>
|
||||
<div class="darkchat-profile-hero">
|
||||
<span class="darkchat-avatar" :style="{ background: avatarGradient(darkchat.profile.avatarSeed) }">{{ avatarGlyph(darkchat.profile.avatarSeed) }}</span>
|
||||
<h2>{{ darkchat.profile.alias }}</h2><button type="button" @click="copyIdentity(darkchat.profile.darkId)">{{ darkchat.profile.darkId }} <Copy :size="12" /></button>
|
||||
</div>
|
||||
<label class="darkchat-input"><span>{{ t('alias') }}</span><input v-model="aliasDraft" maxlength="32" /></label>
|
||||
<div class="darkchat-settings-group">
|
||||
<label class="darkchat-select"><span><Bell :size="17" />{{ t('notificationPrivacy') }}</span><select v-model="notificationMode"><option value="full">{{ t('notificationFull') }}</option><option value="private">{{ t('notificationPrivate') }}</option><option value="hidden">{{ t('notificationHidden') }}</option></select></label>
|
||||
<div class="darkchat-select"><span><Bell :size="17" />{{ t('notificationPrivacy') }}</span><DarkChatSelect :model-value="notificationMode" :options="notificationOptions" :label="t('notificationPrivacy')" @update:model-value="selectNotificationMode" /></div>
|
||||
<label><span><ShieldCheck :size="17" />{{ t('shareActivity') }}</span><input v-model="activityVisible" type="checkbox" /></label>
|
||||
</div>
|
||||
<div class="darkchat-qr-card">
|
||||
@@ -735,7 +814,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="reportOpen" class="darkchat-modal-backdrop">
|
||||
<section class="darkchat-modal"><span class="darkchat-modal__icon darkchat-modal__icon--danger"><ShieldOff :size="25" /></span><h2>{{ t('reportUser') }}</h2><select v-model="reportReason"><option value="spam">{{ t('reportSpam') }}</option><option value="harassment">{{ t('reportHarassment') }}</option><option value="threats">{{ t('reportThreats') }}</option><option value="illegal">{{ t('reportIllegal') }}</option><option value="other">{{ t('reportOther') }}</option></select><textarea v-model="reportDetails" maxlength="500" :placeholder="t('reportDetails')" /><button type="button" class="darkchat-danger" @click="submitReport">{{ t('submitReport') }}</button><button type="button" @click="reportOpen = false; selectedMessage = null">{{ phone.t('Common.cancel') }}</button></section>
|
||||
<section class="darkchat-modal"><span class="darkchat-modal__icon darkchat-modal__icon--danger"><ShieldOff :size="25" /></span><h2>{{ t('reportUser') }}</h2><DarkChatSelect class="darkchat-report-select" :model-value="reportReason" :options="reportOptions" :label="t('reportUser')" @update:model-value="selectReportReason" /><textarea v-model="reportDetails" maxlength="500" :placeholder="t('reportDetails')" /><button type="button" class="darkchat-danger" @click="submitReport">{{ t('submitReport') }}</button><button type="button" @click="reportOpen = false; selectedMessage = null">{{ phone.t('Common.cancel') }}</button></section>
|
||||
</div>
|
||||
|
||||
<Transition name="darkchat-toast"><div v-if="toast" class="darkchat-toast">{{ toast }}</div></Transition>
|
||||
|
||||
@@ -203,8 +203,8 @@ function observeMore(): void {
|
||||
|
||||
function openMedia(entry: PhoneMedia): void {
|
||||
if (requestedMessageMedia.value) {
|
||||
messageMedia.complete(entry)
|
||||
void router.replace('/apps/messages')
|
||||
const returnPath = messageMedia.complete(entry)
|
||||
if (returnPath) void router.replace(returnPath)
|
||||
return
|
||||
}
|
||||
landscapeViewer.value = false
|
||||
@@ -215,8 +215,7 @@ function openMedia(entry: PhoneMedia): void {
|
||||
}
|
||||
|
||||
function cancelMessageSelection(): void {
|
||||
messageMedia.cancel()
|
||||
void router.replace('/apps/messages')
|
||||
void router.replace(messageMedia.cancel())
|
||||
}
|
||||
|
||||
function closeMedia(): void {
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
|
||||
import { PHONE_FRAME_COLORS } from '@/config/appearance'
|
||||
import { isLaunchablePhoneApp, PHONE_APPS } from '@/config/apps'
|
||||
import { IFRUIT_AUTH_INPUT_COLORS } from '@/config/ifruit'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import type {
|
||||
@@ -597,10 +596,9 @@ onBeforeUnmount(() => {
|
||||
)
|
||||
}}
|
||||
</k-block-title>
|
||||
<k-list class="bg-black">
|
||||
<k-list>
|
||||
<k-list-input
|
||||
class="relative"
|
||||
:colors="IFRUIT_AUTH_INPUT_COLORS"
|
||||
:value="accountEmail"
|
||||
:label="
|
||||
phone.t(
|
||||
@@ -632,7 +630,6 @@ onBeforeUnmount(() => {
|
||||
</k-list-input>
|
||||
<k-list-input
|
||||
type="password"
|
||||
:colors="IFRUIT_AUTH_INPUT_COLORS"
|
||||
:value="accountPassword"
|
||||
:label="phone.t('Apps.mail.password')"
|
||||
outline
|
||||
@@ -645,7 +642,6 @@ onBeforeUnmount(() => {
|
||||
<k-list-input
|
||||
v-if="accountMode === 'register'"
|
||||
type="password"
|
||||
:colors="IFRUIT_AUTH_INPUT_COLORS"
|
||||
:value="accountConfirm"
|
||||
:label="phone.t('Apps.mail.confirmPassword')"
|
||||
outline
|
||||
|
||||
@@ -43,7 +43,7 @@ Locales["en"] = {
|
||||
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",
|
||||
@@ -54,7 +54,7 @@ Locales["en"] = {
|
||||
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 = {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<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-nwsHiC9b.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-BqvyRdgo.css">
|
||||
<script type="module" crossorigin src="./assets/sky-index-BP8rWcLp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-DyRptftA.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user