Add SurrealDB-backed phone storage and message deletion
- Wire phone, garage, and locker stores to the new storage layer - Add delete flows for messages and emails in the phone UI - Update contact, mail, and message views for the new data model
This commit is contained in:
parent
a8415eb1fd
commit
4532e7b73d
@ -150,6 +150,17 @@ if (isNil QGVAR(PhoneClass)) then { [] call FUNC(initClass); };
|
|||||||
[QGVAR(updateMessageRead), [_messageId]] call CFUNC(localEvent);
|
[QGVAR(updateMessageRead), [_messageId]] call CFUNC(localEvent);
|
||||||
}] call CFUNC(addEventHandler);
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
|
[QGVAR(responseDeleteMessage), {
|
||||||
|
params [["_success", false, [false]], ["_messageId", "", [""]]];
|
||||||
|
|
||||||
|
if (_success) then {
|
||||||
|
diag_log format ["[FORGE:Client:Phone] Message %1 deleted", _messageId];
|
||||||
|
[QGVAR(updateMessageDeleted), [_messageId]] call CFUNC(localEvent);
|
||||||
|
} else {
|
||||||
|
[QEGVAR(notifications,recieveNotification), ["danger", "Message Delete Failed", "Failed to delete message", 4000]] call CFUNC(localEvent);
|
||||||
|
};
|
||||||
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
// Email Response Events
|
// Email Response Events
|
||||||
[QGVAR(responseEmailSent), {
|
[QGVAR(responseEmailSent), {
|
||||||
params [["_emailObj", createHashMap, [createHashMap]]];
|
params [["_emailObj", createHashMap, [createHashMap]]];
|
||||||
@ -215,6 +226,17 @@ if (isNil QGVAR(PhoneClass)) then { [] call FUNC(initClass); };
|
|||||||
[QGVAR(updateEmailRead), [_emailId]] call CFUNC(localEvent);
|
[QGVAR(updateEmailRead), [_emailId]] call CFUNC(localEvent);
|
||||||
}] call CFUNC(addEventHandler);
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
|
[QGVAR(responseDeleteEmail), {
|
||||||
|
params [["_success", false, [false]], ["_emailId", "", [""]]];
|
||||||
|
|
||||||
|
if (_success) then {
|
||||||
|
diag_log format ["[FORGE:Client:Phone] Email %1 deleted", _emailId];
|
||||||
|
[QGVAR(updateEmailDeleted), [_emailId]] call CFUNC(localEvent);
|
||||||
|
} else {
|
||||||
|
[QEGVAR(notifications,recieveNotification), ["danger", "Email Delete Failed", "Failed to delete email", 4000]] call CFUNC(localEvent);
|
||||||
|
};
|
||||||
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
// Cleanup Response Events
|
// Cleanup Response Events
|
||||||
[QGVAR(responseRemovePhone), {
|
[QGVAR(responseRemovePhone), {
|
||||||
params [["_success", false, [false]]];
|
params [["_success", false, [false]]];
|
||||||
@ -269,6 +291,14 @@ if (isNil QGVAR(PhoneClass)) then { [] call FUNC(initClass); };
|
|||||||
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessageThread(%1, %2)", (toJSON _messages), (toJSON _otherUid)]]; };
|
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessageThread(%1, %2)", (toJSON _messages), (toJSON _otherUid)]]; };
|
||||||
}] call CFUNC(addEventHandler);
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
|
[QGVAR(updateMessageDeleted), {
|
||||||
|
params [["_messageId", "", [""]]];
|
||||||
|
|
||||||
|
private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
|
||||||
|
|
||||||
|
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessageDeleted(%1)", (toJSON _messageId)]]; };
|
||||||
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
[QGVAR(updateEmailSent), {
|
[QGVAR(updateEmailSent), {
|
||||||
params [["_emailObj", createHashMap, [createHashMap]]];
|
params [["_emailObj", createHashMap, [createHashMap]]];
|
||||||
|
|
||||||
@ -300,3 +330,11 @@ if (isNil QGVAR(PhoneClass)) then { [] call FUNC(initClass); };
|
|||||||
|
|
||||||
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmailRead(%1)", (toJSON _emailId)]]; };
|
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmailRead(%1)", (toJSON _emailId)]]; };
|
||||||
}] call CFUNC(addEventHandler);
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
|
[QGVAR(updateEmailDeleted), {
|
||||||
|
params [["_emailId", "", [""]]];
|
||||||
|
|
||||||
|
private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
|
||||||
|
|
||||||
|
if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmailDeleted(%1)", (toJSON _emailId)]]; };
|
||||||
|
}] call CFUNC(addEventHandler);
|
||||||
|
|||||||
@ -118,12 +118,23 @@ switch (_event) do {
|
|||||||
diag_log "[FORGE:Client:Phone] No message ID provided for mark read";
|
diag_log "[FORGE:Client:Phone] No message ID provided for mark read";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
case "phone::delete::message": {
|
||||||
|
private _messageId = _data get "messageId";
|
||||||
|
|
||||||
|
if (_messageId isNotEqualTo "") then {
|
||||||
|
["forge_server_phone_requestDeleteMessage", [getPlayerUID player, _messageId, player]] call CFUNC(serverEvent);
|
||||||
|
} else {
|
||||||
|
diag_log "[FORGE:Client:Phone] No message ID provided for delete";
|
||||||
|
};
|
||||||
|
};
|
||||||
case "phone::send::email": {
|
case "phone::send::email": {
|
||||||
private _toUid = _data get "toUid";
|
private _toUid = _data get "toUid";
|
||||||
private _subject = _data get "subject";
|
private _subject = _data get "subject";
|
||||||
private _body = _data get "body";
|
private _body = _data get "body";
|
||||||
|
if (_subject isEqualTo "") then { _subject = "No subject"; };
|
||||||
|
|
||||||
if (_toUid isNotEqualTo "" && _subject isNotEqualTo "" && _body isNotEqualTo "") then {
|
if (_toUid isNotEqualTo "" && _body isNotEqualTo "") then {
|
||||||
|
diag_log format ["[FORGE:Client:Phone] Sending email to %1 subject length %2 body length %3", _toUid, count _subject, count _body];
|
||||||
["forge_server_phone_requestSendEmail", [getPlayerUID player, _toUid, _subject, _body, player]] call CFUNC(serverEvent);
|
["forge_server_phone_requestSendEmail", [getPlayerUID player, _toUid, _subject, _body, player]] call CFUNC(serverEvent);
|
||||||
} else {
|
} else {
|
||||||
diag_log "[FORGE:Client:Phone] Missing required email parameters";
|
diag_log "[FORGE:Client:Phone] Missing required email parameters";
|
||||||
@ -141,6 +152,15 @@ switch (_event) do {
|
|||||||
diag_log "[FORGE:Client:Phone] No email ID provided for mark read";
|
diag_log "[FORGE:Client:Phone] No email ID provided for mark read";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
case "phone::delete::email": {
|
||||||
|
private _emailId = _data get "emailId";
|
||||||
|
|
||||||
|
if (_emailId isNotEqualTo "") then {
|
||||||
|
["forge_server_phone_requestDeleteEmail", [getPlayerUID player, _emailId, player]] call CFUNC(serverEvent);
|
||||||
|
} else {
|
||||||
|
diag_log "[FORGE:Client:Phone] No email ID provided for delete";
|
||||||
|
};
|
||||||
|
};
|
||||||
case "phone::get::notes": {
|
case "phone::get::notes": {
|
||||||
private _notes = GVAR(PhoneClass) call ["getAllNotes", []];
|
private _notes = GVAR(PhoneClass) call ["getAllNotes", []];
|
||||||
|
|
||||||
|
|||||||
@ -1170,6 +1170,7 @@ body::-webkit-scrollbar {
|
|||||||
|
|
||||||
.message-content {
|
.message-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
.message-header {
|
.message-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -1217,9 +1218,58 @@ body::-webkit-scrollbar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-thread-delete-button {
|
||||||
|
border: 1px solid rgba(255, 59, 48, 0.55);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 59, 48, 0.14);
|
||||||
|
color: #ff6b61;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-left: 10px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-thread-delete-button:hover {
|
||||||
|
background: rgba(255, 59, 48, 0.22);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-nav-delete-button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 59, 48, 0.18);
|
||||||
|
color: #ff6b61;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-nav-delete-button:hover {
|
||||||
|
background: rgba(255, 59, 48, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-empty-state {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 190px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-empty-state strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Conversation View */
|
/* Conversation View */
|
||||||
.conversation-view {
|
.conversation-view {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -1460,6 +1510,7 @@ body::-webkit-scrollbar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ---- ../styles/components/mail.css ---- */
|
/* ---- ../styles/components/mail.css ---- */
|
||||||
/* Mail App */
|
/* Mail App */
|
||||||
.mail-content,
|
.mail-content,
|
||||||
@ -1558,8 +1609,7 @@ body::-webkit-scrollbar {
|
|||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mail-send-button,
|
.mail-send-button {
|
||||||
.nav-action-button {
|
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--accent-color);
|
background: var(--accent-color);
|
||||||
@ -1572,12 +1622,6 @@ body::-webkit-scrollbar {
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-action-button {
|
|
||||||
min-width: 32px;
|
|
||||||
min-height: 32px;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-detail {
|
.mail-detail {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@ -1604,6 +1648,23 @@ body::-webkit-scrollbar {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mail-delete-button {
|
||||||
|
margin-top: 18px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(255, 59, 48, 0.55);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 59, 48, 0.14);
|
||||||
|
color: #ff6b61;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 11px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-delete-button:hover {
|
||||||
|
background: rgba(255, 59, 48, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ---- ../styles/components/notes.css ---- */
|
/* ---- ../styles/components/notes.css ---- */
|
||||||
/* Notes App Styles */
|
/* Notes App Styles */
|
||||||
|
|||||||
604
arma/client/addons/phone/ui/_site/dist/app.bundle.js
vendored
604
arma/client/addons/phone/ui/_site/dist/app.bundle.js
vendored
File diff suppressed because it is too large
Load Diff
@ -143,6 +143,48 @@ class App extends Component {
|
|||||||
*/
|
*/
|
||||||
render() {
|
render() {
|
||||||
const { currentApp, selectedContact, showModal, showDeleteModal, noteToDelete, eventToDelete } = this.state;
|
const { currentApp, selectedContact, showModal, showDeleteModal, noteToDelete, eventToDelete } = this.state;
|
||||||
|
const openMessageThread = (contact) => {
|
||||||
|
if (!contact || contact.canMessage === false) return;
|
||||||
|
|
||||||
|
const contactId = contact.contactId || contact.uid || contact.id;
|
||||||
|
if (!contactId) return;
|
||||||
|
|
||||||
|
const { messages = [], rawMessages = [], currentUid = window.__playerUid } = globalState.getState();
|
||||||
|
const existingConversation = messages.find((message) => (message.contactId || message.id) === contactId);
|
||||||
|
const selectedRawMessages = rawMessages.filter((message) =>
|
||||||
|
message &&
|
||||||
|
(
|
||||||
|
(message.from === currentUid && message.to === contactId) ||
|
||||||
|
(message.from === contactId && message.to === currentUid)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const conversation = existingConversation || {
|
||||||
|
...contact,
|
||||||
|
id: contactId,
|
||||||
|
contactId,
|
||||||
|
contactName: contact.fullName || contact.name || contactId,
|
||||||
|
conversation: [],
|
||||||
|
hasConversation: false
|
||||||
|
};
|
||||||
|
|
||||||
|
globalState.setState({
|
||||||
|
currentApp: 'messages',
|
||||||
|
selectedContact: null,
|
||||||
|
showModal: false,
|
||||||
|
showMessageContactPicker: false,
|
||||||
|
selectedConversation: {
|
||||||
|
...conversation,
|
||||||
|
id: contactId,
|
||||||
|
contactId,
|
||||||
|
contactName: conversation.contactName || contact.fullName || contact.name || contactId,
|
||||||
|
conversation: conversation.conversation || []
|
||||||
|
},
|
||||||
|
selectedConversationRaw: {
|
||||||
|
otherUid: contactId,
|
||||||
|
messages: selectedRawMessages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return this.createElement(
|
return this.createElement(
|
||||||
'div',
|
'div',
|
||||||
@ -180,9 +222,24 @@ class App extends Component {
|
|||||||
// Call modal
|
// Call modal
|
||||||
showModal && selectedContact && new Modal({
|
showModal && selectedContact && new Modal({
|
||||||
show: showModal,
|
show: showModal,
|
||||||
title: `Call ${selectedContact.name}?`,
|
title: selectedContact.canCall === false ? (selectedContact.fullName || selectedContact.name) : `Call ${selectedContact.fullName || selectedContact.name}?`,
|
||||||
|
confirmText: selectedContact.canCall === false ? 'Close' : 'Call',
|
||||||
|
cancelText: selectedContact.canCall === false ? 'Back' : 'Cancel',
|
||||||
|
hideCancel: true,
|
||||||
|
hideConfirm: selectedContact.canCall === false,
|
||||||
|
extraActions: selectedContact.canMessage === false || !(selectedContact.contactId || selectedContact.uid || selectedContact.id) ? [] : [{
|
||||||
|
text: 'Text',
|
||||||
|
ariaLabel: `Text ${selectedContact.fullName || selectedContact.name}`,
|
||||||
|
className: 'button secondary',
|
||||||
|
onClick: () => openMessageThread(selectedContact)
|
||||||
|
}],
|
||||||
onClose: () => globalState.setState({ showModal: false, selectedContact: null }),
|
onClose: () => globalState.setState({ showModal: false, selectedContact: null }),
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
|
if (selectedContact.canCall === false) {
|
||||||
|
globalState.setState({ showModal: false, selectedContact: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
globalState.setState({
|
globalState.setState({
|
||||||
phoneNumber: selectedContact.phone,
|
phoneNumber: selectedContact.phone,
|
||||||
showModal: false,
|
showModal: false,
|
||||||
@ -190,7 +247,15 @@ class App extends Component {
|
|||||||
currentApp: 'phone'
|
currentApp: 'phone'
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
children: [this.createElement('p', { role: 'alert' }, `Do you want to call ${selectedContact.name} at ${selectedContact.phone}?`)]
|
children: [
|
||||||
|
this.createElement(
|
||||||
|
'p',
|
||||||
|
{ role: 'alert' },
|
||||||
|
selectedContact.canCall === false
|
||||||
|
? `${selectedContact.fullName || selectedContact.name} is a command broadcast contact. Incoming messages and email are available, but direct calls are disabled.`
|
||||||
|
: `Do you want to call ${selectedContact.fullName || selectedContact.name} at ${selectedContact.phone}?`
|
||||||
|
)
|
||||||
|
]
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Delete note confirmation modal
|
// Delete note confirmation modal
|
||||||
|
|||||||
@ -42,14 +42,17 @@ class ContactItem extends Component {
|
|||||||
*/
|
*/
|
||||||
render() {
|
render() {
|
||||||
const { contact } = this.props;
|
const { contact } = this.props;
|
||||||
|
const displayName = contact.fullName || contact.name;
|
||||||
|
const subtitleParts = [contact.phone];
|
||||||
|
if (contact.system) subtitleParts.push('system contact');
|
||||||
|
|
||||||
return this.createElement(
|
return this.createElement(
|
||||||
'li',
|
'li',
|
||||||
{
|
{
|
||||||
className: 'contact-item',
|
className: `contact-item${contact.system ? ' system-contact' : ''}`,
|
||||||
onClick: this.handleClick,
|
onClick: this.handleClick,
|
||||||
role: 'button',
|
role: 'button',
|
||||||
'aria-label': `Contact ${contact.name}`,
|
'aria-label': `Contact ${displayName}`,
|
||||||
},
|
},
|
||||||
// Avatar section
|
// Avatar section
|
||||||
this.createElement(
|
this.createElement(
|
||||||
@ -61,7 +64,12 @@ class ContactItem extends Component {
|
|||||||
contact.avatar
|
contact.avatar
|
||||||
),
|
),
|
||||||
// Contact information section
|
// Contact information section
|
||||||
this.createElement('div', { className: 'contact-info' }, this.createElement('h3', {}, contact.name), this.createElement('p', { 'aria-label': 'Phone number' }, contact.phone))
|
this.createElement(
|
||||||
|
'div',
|
||||||
|
{ className: 'contact-info' },
|
||||||
|
this.createElement('h3', {}, displayName),
|
||||||
|
this.createElement('p', { 'aria-label': 'Phone number' }, subtitleParts.filter(Boolean).join(' - '))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,27 +3,86 @@
|
|||||||
class MailComposer extends Component {
|
class MailComposer extends Component {
|
||||||
constructor(props = {}) {
|
constructor(props = {}) {
|
||||||
super(props);
|
super(props);
|
||||||
|
const contacts = this.emailableContacts(props.contacts || []);
|
||||||
|
const defaultRecipient = contacts.length === 1 ? (contacts[0].uid || contacts[0].id || '') : '';
|
||||||
this.state = {
|
this.state = {
|
||||||
toUid: '',
|
toUid: defaultRecipient,
|
||||||
subject: '',
|
subject: '',
|
||||||
body: ''
|
body: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.toRef = null;
|
||||||
|
this.subjectRef = null;
|
||||||
|
this.bodyRef = null;
|
||||||
|
this.lastSendAt = 0;
|
||||||
|
|
||||||
this.handleSend = this.handleSend.bind(this);
|
this.handleSend = this.handleSend.bind(this);
|
||||||
|
this.syncSubject = this.syncSubject.bind(this);
|
||||||
|
this.syncBody = this.syncBody.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSend() {
|
emailableContacts(contacts = []) {
|
||||||
const toUid = (this.state.toUid || '').trim();
|
return contacts.filter((contact) => contact && contact.canEmail !== false && (contact.uid || contact.id));
|
||||||
const subject = (this.state.subject || '').trim();
|
}
|
||||||
const body = (this.state.body || '').trim();
|
|
||||||
|
|
||||||
if (!toUid || !subject || !body) return;
|
readField(id, ref, fallback = '') {
|
||||||
|
const scopedElement = this.element ? this.element.querySelector(`#${id}`) : null;
|
||||||
|
const documentElement = typeof document !== 'undefined' ? document.getElementById(id) : null;
|
||||||
|
const element = scopedElement || documentElement || ref;
|
||||||
|
if (!element) return fallback;
|
||||||
|
|
||||||
|
if (typeof element.value === 'string' && element.value.length > 0) {
|
||||||
|
return element.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof element.textContent === 'string' && element.textContent.length > 0) {
|
||||||
|
return element.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSubject(event) {
|
||||||
|
this.state.subject = event?.target?.value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
syncBody(event) {
|
||||||
|
this.state.body = event?.target?.value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSend(event) {
|
||||||
|
event?.preventDefault?.();
|
||||||
|
event?.stopPropagation?.();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastSendAt < 500) return;
|
||||||
|
|
||||||
|
const toUid = this.readField('phone-mail-recipient', this.toRef, this.state.toUid).trim();
|
||||||
|
const subject = this.readField('phone-mail-subject', this.subjectRef, this.state.subject).trim() || 'No subject';
|
||||||
|
const body = this.readField('phone-mail-body', this.bodyRef, this.state.body).trim();
|
||||||
|
|
||||||
|
if (!toUid || !body) {
|
||||||
|
console.warn('MailComposer: missing required email fields', {
|
||||||
|
hasRecipient: !!toUid,
|
||||||
|
hasSubject: subject !== 'No subject',
|
||||||
|
hasBody: !!body,
|
||||||
|
toUid,
|
||||||
|
subjectLength: subject.length,
|
||||||
|
bodyLength: body.length
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastSendAt = now;
|
||||||
|
|
||||||
if (typeof A3API !== 'undefined' && A3API.SendAlert) {
|
if (typeof A3API !== 'undefined' && A3API.SendAlert) {
|
||||||
|
console.log('MailComposer: sending email', { toUid, subjectLength: subject.length, bodyLength: body.length });
|
||||||
A3API.SendAlert(JSON.stringify({
|
A3API.SendAlert(JSON.stringify({
|
||||||
event: 'phone::send::email',
|
event: 'phone::send::email',
|
||||||
data: { toUid, subject, body }
|
data: { toUid, subject, body }
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
console.warn('MailComposer: A3API.SendAlert unavailable');
|
||||||
}
|
}
|
||||||
|
|
||||||
globalState.setState({
|
globalState.setState({
|
||||||
@ -33,14 +92,14 @@ class MailComposer extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderContactOptions() {
|
renderContactOptions() {
|
||||||
const contacts = this.props.contacts || [];
|
const contacts = this.emailableContacts(this.props.contacts || []);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
this.createElement('option', { value: '' }, 'Select recipient'),
|
this.createElement('option', { value: '' }, 'Select recipient'),
|
||||||
...contacts.map((contact) => this.createElement(
|
...contacts.map((contact) => this.createElement(
|
||||||
'option',
|
'option',
|
||||||
{ value: contact.uid || contact.id },
|
{ value: contact.uid || contact.id },
|
||||||
`${contact.name || 'Unknown'}${contact.email ? ` (${contact.email})` : ''}`
|
`${contact.fullName || contact.name || 'Unknown'}${contact.email ? ` (${contact.email})` : ''}`
|
||||||
))
|
))
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -54,8 +113,17 @@ class MailComposer extends Component {
|
|||||||
this.createElement(
|
this.createElement(
|
||||||
'select',
|
'select',
|
||||||
{
|
{
|
||||||
|
id: 'phone-mail-recipient',
|
||||||
|
name: 'phone-mail-recipient',
|
||||||
value: this.state.toUid,
|
value: this.state.toUid,
|
||||||
|
onInput: (event) => { this.state.toUid = event.target.value; },
|
||||||
onChange: (event) => { this.state.toUid = event.target.value; },
|
onChange: (event) => { this.state.toUid = event.target.value; },
|
||||||
|
ref: (element) => {
|
||||||
|
this.toRef = element;
|
||||||
|
if (element && this.state.toUid && !element.value) {
|
||||||
|
element.value = this.state.toUid;
|
||||||
|
}
|
||||||
|
},
|
||||||
'aria-label': 'Email recipient'
|
'aria-label': 'Email recipient'
|
||||||
},
|
},
|
||||||
...this.renderContactOptions()
|
...this.renderContactOptions()
|
||||||
@ -64,17 +132,27 @@ class MailComposer extends Component {
|
|||||||
this.createElement('label', {},
|
this.createElement('label', {},
|
||||||
'Subject',
|
'Subject',
|
||||||
this.createElement('input', {
|
this.createElement('input', {
|
||||||
|
id: 'phone-mail-subject',
|
||||||
|
name: 'phone-mail-subject',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
value: this.state.subject,
|
value: this.state.subject,
|
||||||
onInput: (event) => { this.state.subject = event.target.value; },
|
onInput: this.syncSubject,
|
||||||
|
onChange: this.syncSubject,
|
||||||
|
onKeyUp: this.syncSubject,
|
||||||
|
ref: (element) => { this.subjectRef = element; },
|
||||||
placeholder: 'Subject'
|
placeholder: 'Subject'
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
this.createElement('label', {},
|
this.createElement('label', {},
|
||||||
'Message',
|
'Message',
|
||||||
this.createElement('textarea', {
|
this.createElement('textarea', {
|
||||||
|
id: 'phone-mail-body',
|
||||||
|
name: 'phone-mail-body',
|
||||||
value: this.state.body,
|
value: this.state.body,
|
||||||
onInput: (event) => { this.state.body = event.target.value; },
|
onInput: this.syncBody,
|
||||||
|
onChange: this.syncBody,
|
||||||
|
onKeyUp: this.syncBody,
|
||||||
|
ref: (element) => { this.bodyRef = element; },
|
||||||
placeholder: 'Write email body...',
|
placeholder: 'Write email body...',
|
||||||
rows: 8
|
rows: 8
|
||||||
})
|
})
|
||||||
@ -84,7 +162,8 @@ class MailComposer extends Component {
|
|||||||
{
|
{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
className: 'mail-send-button',
|
className: 'mail-send-button',
|
||||||
onClick: this.handleSend
|
onClick: this.handleSend,
|
||||||
|
onMouseDown: this.handleSend
|
||||||
},
|
},
|
||||||
'Send'
|
'Send'
|
||||||
)
|
)
|
||||||
|
|||||||
@ -30,6 +30,17 @@ class MailDetail extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleDeleteEmail(emailId) {
|
||||||
|
if (!emailId) return;
|
||||||
|
|
||||||
|
if (typeof A3API !== 'undefined' && A3API.SendAlert) {
|
||||||
|
A3API.SendAlert(JSON.stringify({
|
||||||
|
event: 'phone::delete::email',
|
||||||
|
data: { emailId }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { email } = this.props;
|
const { email } = this.props;
|
||||||
|
|
||||||
@ -46,7 +57,16 @@ class MailDetail extends Component {
|
|||||||
this.createElement('span', {}, `To: ${this.resolveContactName(email.to) || 'Unknown'}`),
|
this.createElement('span', {}, `To: ${this.resolveContactName(email.to) || 'Unknown'}`),
|
||||||
this.createElement('span', {}, this.formatEmailTime(email.timestamp))
|
this.createElement('span', {}, this.formatEmailTime(email.timestamp))
|
||||||
),
|
),
|
||||||
this.createElement('p', { className: 'mail-body' }, email.body || '')
|
this.createElement('p', { className: 'mail-body' }, email.body || ''),
|
||||||
|
this.createElement(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
className: 'mail-delete-button',
|
||||||
|
onClick: () => this.handleDeleteEmail(email.id)
|
||||||
|
},
|
||||||
|
'Delete Email'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,9 +18,17 @@ function initializeMailApp(container) {
|
|||||||
element: 'button',
|
element: 'button',
|
||||||
props: {
|
props: {
|
||||||
type: 'button',
|
type: 'button',
|
||||||
className: 'nav-action-button',
|
className: 'nav-button add-button',
|
||||||
onClick: () => globalState.setState({ showEmailComposer: true, selectedEmail: null }),
|
onClick: () => globalState.setState({ showEmailComposer: true, selectedEmail: null }),
|
||||||
'aria-label': 'Compose email'
|
'aria-label': 'Compose email',
|
||||||
|
style: {
|
||||||
|
fontSize: '24px',
|
||||||
|
padding: '0 15px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--accent-color)',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
content: '+'
|
content: '+'
|
||||||
} : null
|
} : null
|
||||||
|
|||||||
@ -146,6 +146,10 @@ class ConversationView extends Component {
|
|||||||
const { newMessage } = this.state;
|
const { newMessage } = this.state;
|
||||||
const { conversation } = this.props;
|
const { conversation } = this.props;
|
||||||
|
|
||||||
|
if (conversation && conversation.canMessage === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (newMessage.trim()) {
|
if (newMessage.trim()) {
|
||||||
// Create new message object
|
// Create new message object
|
||||||
const newMessageObj = {
|
const newMessageObj = {
|
||||||
@ -229,6 +233,9 @@ class ConversationView extends Component {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
renderMessageForm() {
|
renderMessageForm() {
|
||||||
|
const { conversation } = this.props;
|
||||||
|
const canMessage = !conversation || conversation.canMessage !== false;
|
||||||
|
|
||||||
return this.createElement(
|
return this.createElement(
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
@ -238,9 +245,11 @@ class ConversationView extends Component {
|
|||||||
},
|
},
|
||||||
this.createElement('textarea', {
|
this.createElement('textarea', {
|
||||||
className: 'message-input',
|
className: 'message-input',
|
||||||
placeholder: 'Type a message...',
|
placeholder: canMessage ? 'Type a message...' : 'Replies disabled for this contact',
|
||||||
value: this.state.newMessage,
|
value: this.state.newMessage,
|
||||||
|
disabled: !canMessage,
|
||||||
onInput: (e) => {
|
onInput: (e) => {
|
||||||
|
if (!canMessage) return;
|
||||||
this.handleInputChange(e);
|
this.handleInputChange(e);
|
||||||
// Auto-grow logic
|
// Auto-grow logic
|
||||||
if (e.target) {
|
if (e.target) {
|
||||||
@ -250,7 +259,7 @@ class ConversationView extends Component {
|
|||||||
},
|
},
|
||||||
onKeyDown: (e) => {
|
onKeyDown: (e) => {
|
||||||
// Send message on Enter key (but not Shift+Enter)
|
// Send message on Enter key (but not Shift+Enter)
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (canMessage && e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.handleSendMessage();
|
this.handleSendMessage();
|
||||||
}
|
}
|
||||||
@ -272,7 +281,8 @@ class ConversationView extends Component {
|
|||||||
type: 'button',
|
type: 'button',
|
||||||
className: 'send-button',
|
className: 'send-button',
|
||||||
onClick: this.handleSendMessage,
|
onClick: this.handleSendMessage,
|
||||||
'aria-label': 'Send message'
|
disabled: !canMessage,
|
||||||
|
'aria-label': canMessage ? 'Send message' : 'Replies disabled'
|
||||||
},
|
},
|
||||||
this.createElement('img', {
|
this.createElement('img', {
|
||||||
src: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>',
|
src: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>',
|
||||||
|
|||||||
@ -20,6 +20,7 @@ class MessageItem extends Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.handleClick = this.handleClick.bind(this);
|
this.handleClick = this.handleClick.bind(this);
|
||||||
|
this.handleDeleteClick = this.handleDeleteClick.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -33,6 +34,19 @@ class MessageItem extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles delete clicks without opening the conversation.
|
||||||
|
* @param {Event} event - Click event
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
handleDeleteClick(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
const { onDelete, message } = this.props;
|
||||||
|
if (onDelete) {
|
||||||
|
onDelete(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats the timestamp into a relative time string
|
* Formats the timestamp into a relative time string
|
||||||
* @param {Date} timestamp - The timestamp to format
|
* @param {Date} timestamp - The timestamp to format
|
||||||
@ -40,8 +54,12 @@ class MessageItem extends Component {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
formatTime(timestamp) {
|
formatTime(timestamp) {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const messageTime = new Date(timestamp);
|
const messageTime = new Date(timestamp);
|
||||||
|
if (Number.isNaN(messageTime.getTime())) return '';
|
||||||
|
|
||||||
const diffInHours = (now - messageTime) / (1000 * 60 * 60);
|
const diffInHours = (now - messageTime) / (1000 * 60 * 60);
|
||||||
|
|
||||||
if (diffInHours < 1) {
|
if (diffInHours < 1) {
|
||||||
@ -91,7 +109,7 @@ class MessageItem extends Component {
|
|||||||
'span',
|
'span',
|
||||||
{
|
{
|
||||||
className: 'message-time',
|
className: 'message-time',
|
||||||
'aria-label': `Sent ${this.formatTime(message.timestamp)}`,
|
'aria-label': message.timestamp ? `Sent ${this.formatTime(message.timestamp)}` : '',
|
||||||
},
|
},
|
||||||
this.formatTime(message.timestamp)
|
this.formatTime(message.timestamp)
|
||||||
)
|
)
|
||||||
@ -105,6 +123,8 @@ class MessageItem extends Component {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
renderMessagePreview(message) {
|
renderMessagePreview(message) {
|
||||||
|
const preview = message.hasConversation ? message.lastMessage : 'Start conversation';
|
||||||
|
|
||||||
return this.createElement(
|
return this.createElement(
|
||||||
'div',
|
'div',
|
||||||
{ className: 'message-preview' },
|
{ className: 'message-preview' },
|
||||||
@ -114,7 +134,7 @@ class MessageItem extends Component {
|
|||||||
role: 'text',
|
role: 'text',
|
||||||
'aria-label': 'Last message',
|
'aria-label': 'Last message',
|
||||||
},
|
},
|
||||||
message.lastMessage
|
preview
|
||||||
),
|
),
|
||||||
message.unread > 0 &&
|
message.unread > 0 &&
|
||||||
this.createElement(
|
this.createElement(
|
||||||
@ -136,6 +156,7 @@ class MessageItem extends Component {
|
|||||||
render() {
|
render() {
|
||||||
const { message } = this.props;
|
const { message } = this.props;
|
||||||
const initials = this.getContactInitials(message.contactName);
|
const initials = this.getContactInitials(message.contactName);
|
||||||
|
const canDelete = Array.isArray(message.conversation) && message.conversation.length > 0;
|
||||||
|
|
||||||
return this.createElement(
|
return this.createElement(
|
||||||
'div',
|
'div',
|
||||||
@ -159,7 +180,22 @@ class MessageItem extends Component {
|
|||||||
},
|
},
|
||||||
initials
|
initials
|
||||||
),
|
),
|
||||||
this.createElement('div', { className: 'message-content' }, this.renderMessageHeader(message), this.renderMessagePreview(message))
|
this.createElement(
|
||||||
|
'div',
|
||||||
|
{ className: 'message-content' },
|
||||||
|
this.renderMessageHeader(message),
|
||||||
|
this.renderMessagePreview(message)
|
||||||
|
),
|
||||||
|
canDelete ? this.createElement(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
className: 'message-thread-delete-button',
|
||||||
|
'aria-label': `Delete conversation with ${message.contactName}`,
|
||||||
|
onClick: this.handleDeleteClick
|
||||||
|
},
|
||||||
|
'Delete'
|
||||||
|
) : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ class MessagesList extends Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
filteredMessages: props.messages || [],
|
filteredMessages: this.buildRows(props.messages || [], props.contacts || [], ''),
|
||||||
searchTerm: ''
|
searchTerm: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -26,24 +26,94 @@ class MessagesList extends Component {
|
|||||||
* @param {Object} nextProps - Next props
|
* @param {Object} nextProps - Next props
|
||||||
*/
|
*/
|
||||||
componentWillReceiveProps(nextProps) {
|
componentWillReceiveProps(nextProps) {
|
||||||
if (nextProps.messages !== this.props.messages) {
|
if (
|
||||||
|
nextProps.messages !== this.props.messages ||
|
||||||
|
nextProps.contacts !== this.props.contacts ||
|
||||||
|
nextProps.includeContacts !== this.props.includeContacts ||
|
||||||
|
nextProps.includeContactsOnSearch !== this.props.includeContactsOnSearch
|
||||||
|
) {
|
||||||
// Re-apply current search filter to new messages
|
// Re-apply current search filter to new messages
|
||||||
this.handleSearch(this.state.searchTerm);
|
this.handleSearch(this.state.searchTerm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildRows(messages = [], contacts = [], searchTerm = '') {
|
||||||
|
const searchTermLower = searchTerm.toLowerCase();
|
||||||
|
const includeContacts = this.props.includeContacts === true || (this.props.includeContactsOnSearch === true && searchTermLower.length > 0);
|
||||||
|
const byContactId = new Map();
|
||||||
|
const contactByUid = new Map();
|
||||||
|
|
||||||
|
contacts
|
||||||
|
.filter((contact) => contact && contact.uid)
|
||||||
|
.forEach((contact) => contactByUid.set(contact.uid, contact));
|
||||||
|
|
||||||
|
messages.forEach((message) => {
|
||||||
|
if (!message) return;
|
||||||
|
const contactId = message.contactId || message.id;
|
||||||
|
const contact = contactByUid.get(contactId) || {};
|
||||||
|
|
||||||
|
byContactId.set(contactId, {
|
||||||
|
...contact,
|
||||||
|
...message,
|
||||||
|
id: contactId,
|
||||||
|
contactId,
|
||||||
|
contactName: message.contactName || contact.fullName || contact.name || contactId,
|
||||||
|
phone: contact.phone || message.phone || '',
|
||||||
|
email: contact.email || message.email || '',
|
||||||
|
canCall: contact.canCall !== false,
|
||||||
|
canMessage: contact.canMessage !== false,
|
||||||
|
hasConversation: Array.isArray(message.conversation) && message.conversation.length > 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (includeContacts) {
|
||||||
|
contacts
|
||||||
|
.filter((contact) => contact && contact.uid && contact.canMessage !== false)
|
||||||
|
.forEach((contact) => {
|
||||||
|
if (byContactId.has(contact.uid)) return;
|
||||||
|
|
||||||
|
byContactId.set(contact.uid, {
|
||||||
|
id: contact.uid,
|
||||||
|
contactId: contact.uid,
|
||||||
|
contactName: contact.fullName || contact.name || contact.uid,
|
||||||
|
fullName: contact.fullName || contact.name || contact.uid,
|
||||||
|
name: contact.name || contact.fullName || contact.uid,
|
||||||
|
phone: contact.phone || '',
|
||||||
|
email: contact.email || '',
|
||||||
|
avatar: contact.avatar,
|
||||||
|
canCall: contact.canCall !== false,
|
||||||
|
canMessage: contact.canMessage !== false,
|
||||||
|
lastMessage: 'Start conversation',
|
||||||
|
timestamp: null,
|
||||||
|
unread: 0,
|
||||||
|
conversation: [],
|
||||||
|
hasConversation: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byContactId.values()).filter((message) => {
|
||||||
|
if (!searchTermLower) return true;
|
||||||
|
|
||||||
|
return [
|
||||||
|
message.contactName,
|
||||||
|
message.lastMessage,
|
||||||
|
message.contactId,
|
||||||
|
message.id,
|
||||||
|
message.phone,
|
||||||
|
message.email
|
||||||
|
].some((value) => (value || '').toString().toLowerCase().includes(searchTermLower));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter messages based on search term
|
* Filter messages based on search term
|
||||||
* @param {string} searchTerm - The search term to filter messages
|
* @param {string} searchTerm - The search term to filter messages
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
handleSearch(searchTerm) {
|
handleSearch(searchTerm) {
|
||||||
const { messages = [] } = this.props;
|
const { messages = [], contacts = [] } = this.props;
|
||||||
const searchTermLower = searchTerm.toLowerCase();
|
const filtered = this.buildRows(messages, contacts, searchTerm);
|
||||||
|
|
||||||
const filtered = messages.filter(message =>
|
|
||||||
message.contactName.toLowerCase().includes(searchTermLower)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
filteredMessages: filtered,
|
filteredMessages: filtered,
|
||||||
@ -57,14 +127,26 @@ class MessagesList extends Component {
|
|||||||
* @returns {Array<MessageItem>} Array of MessageItem components
|
* @returns {Array<MessageItem>} Array of MessageItem components
|
||||||
*/
|
*/
|
||||||
renderMessageItems() {
|
renderMessageItems() {
|
||||||
const { onMessageClick } = this.props;
|
const { onMessageClick, onMessageDelete } = this.props;
|
||||||
const { filteredMessages } = this.state;
|
const { filteredMessages } = this.state;
|
||||||
|
|
||||||
|
if (!filteredMessages.length) {
|
||||||
|
return [
|
||||||
|
this.createElement(
|
||||||
|
'div',
|
||||||
|
{ className: 'messages-empty-state' },
|
||||||
|
this.createElement('strong', {}, this.props.emptyTitle || 'No conversations'),
|
||||||
|
this.createElement('span', {}, this.props.emptySubtitle || 'Tap + to start a new conversation.')
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return filteredMessages.map(
|
return filteredMessages.map(
|
||||||
(message) =>
|
(message) =>
|
||||||
new MessageItem({
|
new MessageItem({
|
||||||
message,
|
message,
|
||||||
onClick: onMessageClick,
|
onClick: onMessageClick,
|
||||||
|
onDelete: onMessageDelete,
|
||||||
key: message.id,
|
key: message.id,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -88,7 +170,7 @@ class MessagesList extends Component {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
new SearchBar({
|
new SearchBar({
|
||||||
placeholder: 'Search by contact name...',
|
placeholder: this.props.searchPlaceholder || 'Search by contact name...',
|
||||||
onSearch: this.handleSearch.bind(this),
|
onSearch: this.handleSearch.bind(this),
|
||||||
value: searchTerm
|
value: searchTerm
|
||||||
}),
|
}),
|
||||||
@ -108,4 +190,4 @@ class MessagesList extends Component {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,61 +1,119 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview Main entry point for the Messages application
|
* @fileoverview Main entry point for the Messages application.
|
||||||
*
|
|
||||||
* This module initializes the Messages app UI, including:
|
|
||||||
* - Rendering the navigation bar with the app title or contact name
|
|
||||||
* - Displaying either the messages list or a conversation view
|
|
||||||
* - Handling navigation between the list and conversation
|
|
||||||
*
|
|
||||||
* The navigation bar shows "Messages" on the list, and the contact's name with a back button in a conversation.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Initialize the messages app
|
|
||||||
function initializeMessagesApp(container) {
|
function initializeMessagesApp(container) {
|
||||||
// Get current messages and selected conversation from global state
|
const { messages = [], contacts = [], selectedConversation, showMessageContactPicker } = globalState.getState();
|
||||||
const { messages, selectedConversation } = globalState.getState();
|
|
||||||
const appContainer = document.createElement('div');
|
const appContainer = document.createElement('div');
|
||||||
|
|
||||||
|
const openConversation = (conversation) => {
|
||||||
|
if (!conversation) return;
|
||||||
|
|
||||||
|
const contactId = conversation.contactId || conversation.uid || conversation.id;
|
||||||
|
const { rawMessages = [], currentUid = window.__playerUid } = globalState.getState();
|
||||||
|
const selectedRawMessages = rawMessages.filter((message) =>
|
||||||
|
message &&
|
||||||
|
(
|
||||||
|
(message.from === currentUid && message.to === contactId) ||
|
||||||
|
(message.from === contactId && message.to === currentUid)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
globalState.setState({
|
||||||
|
selectedConversation: {
|
||||||
|
...conversation,
|
||||||
|
id: contactId,
|
||||||
|
contactId,
|
||||||
|
contactName: conversation.contactName || conversation.fullName || conversation.name || contactId,
|
||||||
|
conversation: conversation.conversation || []
|
||||||
|
},
|
||||||
|
selectedConversationRaw: {
|
||||||
|
otherUid: contactId,
|
||||||
|
messages: selectedRawMessages
|
||||||
|
},
|
||||||
|
showMessageContactPicker: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteConversationMessages = (conversation) => {
|
||||||
|
const messageIds = ((conversation && conversation.conversation) || [])
|
||||||
|
.map((message) => message && message.id)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!messageIds.length) return;
|
||||||
|
|
||||||
|
if (typeof A3API !== 'undefined' && A3API.SendAlert) {
|
||||||
|
messageIds.forEach((messageId) => {
|
||||||
|
A3API.SendAlert(JSON.stringify({
|
||||||
|
event: 'phone::delete::message',
|
||||||
|
data: { messageId }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
appContainer.className = 'app-container';
|
appContainer.className = 'app-container';
|
||||||
appContainer.setAttribute('role', 'main');
|
appContainer.setAttribute('role', 'main');
|
||||||
appContainer.setAttribute('aria-label', 'Messages');
|
appContainer.setAttribute('aria-label', 'Messages');
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigation bar
|
|
||||||
* - Shows "Messages" on the list
|
|
||||||
* - Shows contact name and back button in a conversation
|
|
||||||
*/
|
|
||||||
const navBar = new NavigationBar({
|
const navBar = new NavigationBar({
|
||||||
title: selectedConversation ? selectedConversation.contactName : 'Messages',
|
title: selectedConversation ? selectedConversation.contactName : (showMessageContactPicker ? 'New Conversation' : 'Messages'),
|
||||||
showBackButton: !!selectedConversation
|
showBackButton: !!selectedConversation || showMessageContactPicker,
|
||||||
|
rightButton: selectedConversation && selectedConversation.conversation && selectedConversation.conversation.length ? {
|
||||||
|
element: 'button',
|
||||||
|
props: {
|
||||||
|
type: 'button',
|
||||||
|
className: 'message-nav-delete-button',
|
||||||
|
onClick: () => {
|
||||||
|
deleteConversationMessages(selectedConversation);
|
||||||
|
globalState.setState({ selectedConversation: null, selectedConversationRaw: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content: 'Delete'
|
||||||
|
} : (!selectedConversation && !showMessageContactPicker) ? {
|
||||||
|
element: 'button',
|
||||||
|
props: {
|
||||||
|
type: 'button',
|
||||||
|
className: 'nav-button add-button',
|
||||||
|
onClick: () => globalState.setState({ showMessageContactPicker: true }),
|
||||||
|
'aria-label': 'Start conversation',
|
||||||
|
style: {
|
||||||
|
fontSize: '24px',
|
||||||
|
padding: '0 15px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--accent-color)',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content: '+'
|
||||||
|
} : null
|
||||||
});
|
});
|
||||||
navBar.mount(appContainer);
|
navBar.mount(appContainer);
|
||||||
|
|
||||||
// Content container for either the list or conversation
|
|
||||||
const contentContainer = document.createElement('div');
|
const contentContainer = document.createElement('div');
|
||||||
contentContainer.className = 'content';
|
contentContainer.className = 'content';
|
||||||
appContainer.appendChild(contentContainer);
|
appContainer.appendChild(contentContainer);
|
||||||
|
|
||||||
/**
|
|
||||||
* Render either the conversation view or the messages list
|
|
||||||
* - If a conversation is selected, show ConversationView
|
|
||||||
* - Otherwise, show MessagesList
|
|
||||||
*/
|
|
||||||
if (selectedConversation) {
|
if (selectedConversation) {
|
||||||
const conversationView = new ConversationView({ conversation: selectedConversation });
|
const conversationView = new ConversationView({ conversation: selectedConversation });
|
||||||
conversationView.mount(contentContainer);
|
conversationView.mount(contentContainer);
|
||||||
} else {
|
} else {
|
||||||
const messagesList = new MessagesList({
|
const messagesList = new MessagesList({
|
||||||
messages,
|
messages,
|
||||||
onMessageClick: (message) => {
|
contacts,
|
||||||
globalState.setState({ selectedConversation: message });
|
includeContacts: showMessageContactPicker,
|
||||||
}
|
includeContactsOnSearch: true,
|
||||||
|
searchPlaceholder: 'Search contacts or conversations...',
|
||||||
|
emptyTitle: showMessageContactPicker ? 'No contacts found' : 'No conversations',
|
||||||
|
emptySubtitle: showMessageContactPicker ? 'Try another search.' : 'Search for a contact to start texting.',
|
||||||
|
onMessageClick: openConversation,
|
||||||
|
onMessageDelete: deleteConversationMessages
|
||||||
});
|
});
|
||||||
messagesList.mount(contentContainer);
|
messagesList.mount(contentContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount the app container
|
|
||||||
container.appendChild(appContainer);
|
container.appendChild(appContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make initialization function globally available
|
window.initializeMessagesApp = initializeMessagesApp;
|
||||||
window.initializeMessagesApp = initializeMessagesApp;
|
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
class Dialpad extends Component {
|
class Dialpad extends Component {
|
||||||
|
static fieldCommanderPhoneNumber = '0160000000';
|
||||||
|
|
||||||
static assetPath(...parts) {
|
static assetPath(...parts) {
|
||||||
return PhoneMedia.base64Path('images', ...parts);
|
return PhoneMedia.base64Path('images', ...parts);
|
||||||
}
|
}
|
||||||
@ -174,7 +176,11 @@ class Dialpad extends Component {
|
|||||||
* @description Initiates a phone call and starts the call timer
|
* @description Initiates a phone call and starts the call timer
|
||||||
*/
|
*/
|
||||||
handleCall() {
|
handleCall() {
|
||||||
if (this.state.phoneNumber && !this.state.isCallActive) {
|
if (
|
||||||
|
this.state.phoneNumber &&
|
||||||
|
!this.state.isCallActive &&
|
||||||
|
this.cleanPhoneNumber(this.state.phoneNumber) !== Dialpad.fieldCommanderPhoneNumber
|
||||||
|
) {
|
||||||
this.setState({
|
this.setState({
|
||||||
isCallActive: true,
|
isCallActive: true,
|
||||||
callDuration: 0,
|
callDuration: 0,
|
||||||
@ -304,7 +310,7 @@ class Dialpad extends Component {
|
|||||||
'aria-label': 'Make call',
|
'aria-label': 'Make call',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isPhoneNumberEmpty) {
|
if (isPhoneNumberEmpty || this.cleanPhoneNumber(phoneNumber) === Dialpad.fieldCommanderPhoneNumber) {
|
||||||
callButtonProps.disabled = true;
|
callButtonProps.disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,9 @@ class HomeIndicator extends Component {
|
|||||||
globalState.setState({
|
globalState.setState({
|
||||||
currentApp: 'home',
|
currentApp: 'home',
|
||||||
selectedConversation: null,
|
selectedConversation: null,
|
||||||
|
selectedConversationRaw: null,
|
||||||
selectedContact: null,
|
selectedContact: null,
|
||||||
|
showMessageContactPicker: false,
|
||||||
showModal: false,
|
showModal: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,11 +66,15 @@ class Modal extends Component {
|
|||||||
* @returns {HTMLElement} The rendered actions element
|
* @returns {HTMLElement} The rendered actions element
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
renderActions(onClose, onConfirm, confirmText = 'Call', cancelText = 'Cancel') {
|
renderActions(onClose, onConfirm, confirmText = 'Call', cancelText = 'Cancel', extraActions = [], hideCancel = false, hideConfirm = false) {
|
||||||
|
if (hideCancel && hideConfirm && !extraActions.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return this.createElement(
|
return this.createElement(
|
||||||
'div',
|
'div',
|
||||||
{ className: 'modal-actions' },
|
{ className: 'modal-actions' },
|
||||||
this.createElement(
|
hideCancel ? null : this.createElement(
|
||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
className: 'button secondary',
|
className: 'button secondary',
|
||||||
@ -80,7 +84,18 @@ class Modal extends Component {
|
|||||||
},
|
},
|
||||||
cancelText
|
cancelText
|
||||||
),
|
),
|
||||||
this.createElement(
|
...extraActions.map((action) => this.createElement(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
className: action.className || 'button secondary',
|
||||||
|
onClick: () => action.onClick?.(),
|
||||||
|
type: 'button',
|
||||||
|
disabled: action.disabled === true,
|
||||||
|
'aria-label': action.ariaLabel || action.text,
|
||||||
|
},
|
||||||
|
action.text
|
||||||
|
)),
|
||||||
|
hideConfirm ? null : this.createElement(
|
||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
className: 'button',
|
className: 'button',
|
||||||
@ -98,7 +113,7 @@ class Modal extends Component {
|
|||||||
* @returns {HTMLElement} The rendered modal element
|
* @returns {HTMLElement} The rendered modal element
|
||||||
*/
|
*/
|
||||||
render() {
|
render() {
|
||||||
const { show, title, children = [], onClose, onConfirm, confirmText, cancelText } = this.props;
|
const { show, title, children = [], onClose, onConfirm, confirmText, cancelText, extraActions = [], hideCancel = false, hideConfirm = false } = this.props;
|
||||||
|
|
||||||
if (!show) {
|
if (!show) {
|
||||||
return this.createElement('div', {
|
return this.createElement('div', {
|
||||||
@ -147,7 +162,7 @@ class Modal extends Component {
|
|||||||
},
|
},
|
||||||
...childElements.filter((child) => child != null)
|
...childElements.filter((child) => child != null)
|
||||||
),
|
),
|
||||||
this.renderActions(onClose, onConfirm, confirmText, cancelText)
|
this.renderActions(onClose, onConfirm, confirmText, cancelText, extraActions, hideCancel, hideConfirm)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,10 +32,18 @@ class NavigationBar extends Component {
|
|||||||
if (currentState.selectedConversation) {
|
if (currentState.selectedConversation) {
|
||||||
globalState.setState({
|
globalState.setState({
|
||||||
selectedConversation: null,
|
selectedConversation: null,
|
||||||
|
selectedConversationRaw: null,
|
||||||
});
|
});
|
||||||
return; // Exit early, don't execute the rest
|
return; // Exit early, don't execute the rest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentState.showMessageContactPicker) {
|
||||||
|
globalState.setState({
|
||||||
|
showMessageContactPicker: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentState.selectedEmail || currentState.showEmailComposer) {
|
if (currentState.selectedEmail || currentState.showEmailComposer) {
|
||||||
globalState.setState({
|
globalState.setState({
|
||||||
selectedEmail: null,
|
selectedEmail: null,
|
||||||
@ -58,7 +66,9 @@ class NavigationBar extends Component {
|
|||||||
currentApp: 'home',
|
currentApp: 'home',
|
||||||
previousApp: null,
|
previousApp: null,
|
||||||
selectedConversation: null,
|
selectedConversation: null,
|
||||||
|
selectedConversationRaw: null,
|
||||||
selectedContact: null,
|
selectedContact: null,
|
||||||
|
showMessageContactPicker: false,
|
||||||
showModal: false,
|
showModal: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -195,8 +195,14 @@ class Component {
|
|||||||
Object.assign(element.style, value);
|
Object.assign(element.style, value);
|
||||||
} else if (key === 'ref' && typeof value === 'function') {
|
} else if (key === 'ref' && typeof value === 'function') {
|
||||||
value(element);
|
value(element);
|
||||||
} else {
|
} else if (typeof value === 'boolean') {
|
||||||
|
if (value) {
|
||||||
|
element.setAttribute(key, key);
|
||||||
|
}
|
||||||
|
} else if (value !== null && value !== undefined) {
|
||||||
element.setAttribute(key, value);
|
element.setAttribute(key, value);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ const initialAppState = {
|
|||||||
// UI state
|
// UI state
|
||||||
selectedContact: null,
|
selectedContact: null,
|
||||||
selectedConversation: null,
|
selectedConversation: null,
|
||||||
|
showMessageContactPicker: false,
|
||||||
newMessage: '',
|
newMessage: '',
|
||||||
currentUid: null,
|
currentUid: null,
|
||||||
|
|
||||||
|
|||||||
@ -110,10 +110,15 @@ function normalizeContacts(contacts) {
|
|||||||
id: uid || contact.phone || name,
|
id: uid || contact.phone || name,
|
||||||
uid,
|
uid,
|
||||||
name,
|
name,
|
||||||
|
fullName: contact.fullName || name,
|
||||||
phone: contact.phone || '',
|
phone: contact.phone || '',
|
||||||
email: contact.email || '',
|
email: contact.email || '',
|
||||||
avatar: contact.avatar || getInitials(name),
|
avatar: contact.avatar || getInitials(name),
|
||||||
online: Boolean(contact.online)
|
online: Boolean(contact.online),
|
||||||
|
system: Boolean(contact.system),
|
||||||
|
canCall: contact.canCall !== false,
|
||||||
|
canMessage: contact.canMessage !== false,
|
||||||
|
canEmail: contact.canEmail !== false
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -260,6 +265,30 @@ function updateMessageRead(messageId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a message from the local phone state after server delete succeeds
|
||||||
|
* @param {string} messageId
|
||||||
|
*/
|
||||||
|
function updateMessageDeleted(messageId) {
|
||||||
|
try {
|
||||||
|
const { rawMessages = [], selectedConversationRaw = null } = globalState.getState();
|
||||||
|
const nextRawMessages = rawMessages.filter(message => message && message.id !== messageId);
|
||||||
|
const statePatch = { rawMessages: nextRawMessages };
|
||||||
|
|
||||||
|
if (selectedConversationRaw && Array.isArray(selectedConversationRaw.messages)) {
|
||||||
|
statePatch.selectedConversationRaw = {
|
||||||
|
...selectedConversationRaw,
|
||||||
|
messages: selectedConversationRaw.messages.filter(message => message && message.id !== messageId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
globalState.setState(statePatch);
|
||||||
|
rebuildMessageSummariesFromRaw();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in updateMessageDeleted:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Transform raw message payloads into UI-friendly summary and thread structures
|
// Transform raw message payloads into UI-friendly summary and thread structures
|
||||||
function rebuildMessageSummariesFromRaw() {
|
function rebuildMessageSummariesFromRaw() {
|
||||||
try {
|
try {
|
||||||
@ -316,6 +345,7 @@ function rebuildMessageSummariesFromRaw() {
|
|||||||
id: otherUid,
|
id: otherUid,
|
||||||
contactId: otherUid,
|
contactId: otherUid,
|
||||||
contactName: contact.name || otherUid,
|
contactName: contact.name || otherUid,
|
||||||
|
canMessage: contact.canMessage !== false,
|
||||||
lastMessage: (last && (last.message || last.text)) || '',
|
lastMessage: (last && (last.message || last.text)) || '',
|
||||||
timestamp: toJsDate(last && last.timestamp),
|
timestamp: toJsDate(last && last.timestamp),
|
||||||
unread: arr.filter(m => m.read === false && m.to === currentUid).length || 0,
|
unread: arr.filter(m => m.read === false && m.to === currentUid).length || 0,
|
||||||
@ -336,6 +366,7 @@ function rebuildMessageSummariesFromRaw() {
|
|||||||
id: selectedConversationRaw.otherUid,
|
id: selectedConversationRaw.otherUid,
|
||||||
contactId: selectedConversationRaw.otherUid,
|
contactId: selectedConversationRaw.otherUid,
|
||||||
contactName: contact.name,
|
contactName: contact.name,
|
||||||
|
canMessage: contact.canMessage !== false,
|
||||||
lastMessage: thread.length ? (thread[thread.length - 1].message || thread[thread.length - 1].text) : '',
|
lastMessage: thread.length ? (thread[thread.length - 1].message || thread[thread.length - 1].text) : '',
|
||||||
timestamp: thread.length ? toJsDate(thread[thread.length - 1].timestamp) : new Date(),
|
timestamp: thread.length ? toJsDate(thread[thread.length - 1].timestamp) : new Date(),
|
||||||
unread: thread.filter(m => m.read === false && m.to === currentUid).length || 0,
|
unread: thread.filter(m => m.read === false && m.to === currentUid).length || 0,
|
||||||
@ -458,6 +489,22 @@ function updateEmailRead(emailId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an email from the local phone state after server delete succeeds
|
||||||
|
* @param {string} emailId
|
||||||
|
*/
|
||||||
|
function updateEmailDeleted(emailId) {
|
||||||
|
try {
|
||||||
|
const { emails = [], selectedEmail = null } = globalState.getState();
|
||||||
|
globalState.setState({
|
||||||
|
emails: emails.filter(email => email && email.id !== emailId),
|
||||||
|
selectedEmail: selectedEmail && selectedEmail.id === emailId ? null : selectedEmail
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in updateEmailDeleted:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Debounce variables for notes requests
|
// Debounce variables for notes requests
|
||||||
let lastNotesRequest = 0;
|
let lastNotesRequest = 0;
|
||||||
const NOTES_REQUEST_COOLDOWN = 1000; // 1 second cooldown
|
const NOTES_REQUEST_COOLDOWN = 1000; // 1 second cooldown
|
||||||
@ -853,12 +900,14 @@ window.updateMessageThread = updateMessageThread;
|
|||||||
window.updateMessageSent = updateMessageSent;
|
window.updateMessageSent = updateMessageSent;
|
||||||
window.updateMessageReceived = updateMessageReceived;
|
window.updateMessageReceived = updateMessageReceived;
|
||||||
window.updateMessageRead = updateMessageRead;
|
window.updateMessageRead = updateMessageRead;
|
||||||
|
window.updateMessageDeleted = updateMessageDeleted;
|
||||||
// Emails
|
// Emails
|
||||||
window.requestEmails = requestEmails;
|
window.requestEmails = requestEmails;
|
||||||
window.updateEmails = updateEmails;
|
window.updateEmails = updateEmails;
|
||||||
window.updateEmailSent = updateEmailSent;
|
window.updateEmailSent = updateEmailSent;
|
||||||
window.updateEmailReceived = updateEmailReceived;
|
window.updateEmailReceived = updateEmailReceived;
|
||||||
window.updateEmailRead = updateEmailRead;
|
window.updateEmailRead = updateEmailRead;
|
||||||
|
window.updateEmailDeleted = updateEmailDeleted;
|
||||||
window.requestNotes = requestNotes;
|
window.requestNotes = requestNotes;
|
||||||
window.loadNotes = loadNotes;
|
window.loadNotes = loadNotes;
|
||||||
window.saveNote = saveNote;
|
window.saveNote = saveNote;
|
||||||
|
|||||||
@ -95,8 +95,7 @@
|
|||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mail-send-button,
|
.mail-send-button {
|
||||||
.nav-action-button {
|
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--accent-color);
|
background: var(--accent-color);
|
||||||
@ -109,12 +108,6 @@
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-action-button {
|
|
||||||
min-width: 32px;
|
|
||||||
min-height: 32px;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-detail {
|
.mail-detail {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@ -140,3 +133,20 @@
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mail-delete-button {
|
||||||
|
margin-top: 18px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(255, 59, 48, 0.55);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 59, 48, 0.14);
|
||||||
|
color: #ff6b61;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 11px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-delete-button:hover {
|
||||||
|
background: rgba(255, 59, 48, 0.22);
|
||||||
|
}
|
||||||
|
|||||||
@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
.message-content {
|
.message-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
.message-header {
|
.message-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -79,9 +80,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-thread-delete-button {
|
||||||
|
border: 1px solid rgba(255, 59, 48, 0.55);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 59, 48, 0.14);
|
||||||
|
color: #ff6b61;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-left: 10px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-thread-delete-button:hover {
|
||||||
|
background: rgba(255, 59, 48, 0.22);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-nav-delete-button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 59, 48, 0.18);
|
||||||
|
color: #ff6b61;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-nav-delete-button:hover {
|
||||||
|
background: rgba(255, 59, 48, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-empty-state {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 190px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-empty-state strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Conversation View */
|
/* Conversation View */
|
||||||
.conversation-view {
|
.conversation-view {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -320,4 +370,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,9 +60,6 @@ GVAR(GarageBaseStore) = compileFinal createHashMapFromArray [
|
|||||||
if (isNull _player) exitWith { createHashMap };
|
if (isNull _player) exitWith { createHashMap };
|
||||||
|
|
||||||
private _garage = _self call ["loadHotGarage", [_uid, true]];
|
private _garage = _self call ["loadHotGarage", [_uid, true]];
|
||||||
if (_garage isEqualTo createHashMap) then {
|
|
||||||
["ERROR", format ["Failed to initialize garage for %1! Using fallback garage.", _uid]] call EFUNC(common,log);
|
|
||||||
};
|
|
||||||
|
|
||||||
[CRPC(garage,responseInitGarage), [_garage], _player] call CFUNC(targetEvent);
|
[CRPC(garage,responseInitGarage), [_garage], _player] call CFUNC(targetEvent);
|
||||||
_garage
|
_garage
|
||||||
|
|||||||
@ -60,9 +60,6 @@ GVAR(LockerBaseStore) = compileFinal createHashMapFromArray [
|
|||||||
if (isNull _player) exitWith { createHashMap };
|
if (isNull _player) exitWith { createHashMap };
|
||||||
|
|
||||||
private _locker = _self call ["loadHotLocker", [_uid, true]];
|
private _locker = _self call ["loadHotLocker", [_uid, true]];
|
||||||
if (_locker isEqualTo createHashMap) then {
|
|
||||||
["ERROR", format ["Failed to initialize locker for %1! Using fallback locker.", _uid]] call EFUNC(common,log);
|
|
||||||
};
|
|
||||||
|
|
||||||
[CRPC(locker,responseInitLocker), [_locker], _player] call CFUNC(targetEvent);
|
[CRPC(locker,responseInitLocker), [_locker], _player] call CFUNC(targetEvent);
|
||||||
_locker
|
_locker
|
||||||
|
|||||||
@ -96,7 +96,7 @@ if (isNil QGVAR(PhoneStore)) then { [] call FUNC(initPhoneStore); };
|
|||||||
};
|
};
|
||||||
|
|
||||||
private _recipient = [_toUid] call EFUNC(common,getPlayer);
|
private _recipient = [_toUid] call EFUNC(common,getPlayer);
|
||||||
if (_success && { !isNull _recipient }) then {
|
if (_success && { _toUid isNotEqualTo _fromUid } && { !isNull _recipient }) then {
|
||||||
["forge_client_phone_responseMessageReceived", [_messageObj], _recipient] call CFUNC(targetEvent);
|
["forge_client_phone_responseMessageReceived", [_messageObj], _recipient] call CFUNC(targetEvent);
|
||||||
};
|
};
|
||||||
}] call CFUNC(addEventHandler);
|
}] call CFUNC(addEventHandler);
|
||||||
@ -135,11 +135,24 @@ if (isNil QGVAR(PhoneStore)) then { [] call FUNC(initPhoneStore); };
|
|||||||
if (!isNull _player) then { ["forge_client_phone_responseMarkMessageRead", [_result, _messageId], _player] call CFUNC(targetEvent); };
|
if (!isNull _player) then { ["forge_client_phone_responseMarkMessageRead", [_result, _messageId], _player] call CFUNC(targetEvent); };
|
||||||
}] call CFUNC(addEventHandler);
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
|
[QGVAR(requestDeleteMessage), {
|
||||||
|
params [["_uid", "", [""]], ["_messageId", "", [""]], ["_player", objNull, [objNull]]];
|
||||||
|
|
||||||
|
if (_uid isEqualTo "" || _messageId isEqualTo "") exitWith {
|
||||||
|
diag_log "[FORGE:Server:Phone] Invalid parameters for requestDeleteMessage";
|
||||||
|
};
|
||||||
|
|
||||||
|
private _result = GVAR(PhoneStore) call ["deleteMessage", [_uid, _messageId]];
|
||||||
|
|
||||||
|
if (!isNull _player) then { ["forge_client_phone_responseDeleteMessage", [_result, _messageId], _player] call CFUNC(targetEvent); };
|
||||||
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
// Email Events
|
// Email Events
|
||||||
[QGVAR(requestSendEmail), {
|
[QGVAR(requestSendEmail), {
|
||||||
params [["_fromUid", "", [""]], ["_toUid", "", [""]], ["_subject", "", [""]], ["_body", "", [""]], ["_player", objNull, [objNull]]];
|
params [["_fromUid", "", [""]], ["_toUid", "", [""]], ["_subject", "", [""]], ["_body", "", [""]], ["_player", objNull, [objNull]]];
|
||||||
|
if (_subject isEqualTo "") then { _subject = "No subject"; };
|
||||||
|
|
||||||
if (_fromUid isEqualTo "" || _toUid isEqualTo "" || _subject isEqualTo "" || _body isEqualTo "") exitWith {
|
if (_fromUid isEqualTo "" || _toUid isEqualTo "" || _body isEqualTo "") exitWith {
|
||||||
diag_log "[FORGE:Server:Phone] Invalid parameters for requestSendEmail";
|
diag_log "[FORGE:Server:Phone] Invalid parameters for requestSendEmail";
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -154,7 +167,7 @@ if (isNil QGVAR(PhoneStore)) then { [] call FUNC(initPhoneStore); };
|
|||||||
};
|
};
|
||||||
|
|
||||||
private _recipient = [_toUid] call EFUNC(common,getPlayer);
|
private _recipient = [_toUid] call EFUNC(common,getPlayer);
|
||||||
if (_success && { !isNull _recipient }) then {
|
if (_success && { _toUid isNotEqualTo _fromUid } && { !isNull _recipient }) then {
|
||||||
["forge_client_phone_responseEmailReceived", [_emailObj], _recipient] call CFUNC(targetEvent);
|
["forge_client_phone_responseEmailReceived", [_emailObj], _recipient] call CFUNC(targetEvent);
|
||||||
};
|
};
|
||||||
}] call CFUNC(addEventHandler);
|
}] call CFUNC(addEventHandler);
|
||||||
@ -181,6 +194,18 @@ if (isNil QGVAR(PhoneStore)) then { [] call FUNC(initPhoneStore); };
|
|||||||
if (!isNull _player) then { ["forge_client_phone_responseMarkEmailRead", [_result, _emailId], _player] call CFUNC(targetEvent); };
|
if (!isNull _player) then { ["forge_client_phone_responseMarkEmailRead", [_result, _emailId], _player] call CFUNC(targetEvent); };
|
||||||
}] call CFUNC(addEventHandler);
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
|
[QGVAR(requestDeleteEmail), {
|
||||||
|
params [["_uid", "", [""]], ["_emailId", "", [""]], ["_player", objNull, [objNull]]];
|
||||||
|
|
||||||
|
if (_uid isEqualTo "" || _emailId isEqualTo "") exitWith {
|
||||||
|
diag_log "[FORGE:Server:Phone] Invalid parameters for requestDeleteEmail";
|
||||||
|
};
|
||||||
|
|
||||||
|
private _result = GVAR(PhoneStore) call ["deleteEmail", [_uid, _emailId]];
|
||||||
|
|
||||||
|
if (!isNull _player) then { ["forge_client_phone_responseDeleteEmail", [_result, _emailId], _player] call CFUNC(targetEvent); };
|
||||||
|
}] call CFUNC(addEventHandler);
|
||||||
|
|
||||||
// Cleanup Event
|
// Cleanup Event
|
||||||
[QGVAR(requestRemovePhone), {
|
[QGVAR(requestRemovePhone), {
|
||||||
params [["_uid", "", [""]], ["_player", objNull, [objNull]]];
|
params [["_uid", "", [""]], ["_player", objNull, [objNull]]];
|
||||||
|
|||||||
@ -48,6 +48,9 @@ GVAR(ContactStore) = createHashMapObject [[
|
|||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private _fieldCommanderUid = "field_commander";
|
||||||
|
_self call ["callPhoneBool", ["phone:contacts:add", [_uid, _uid]]];
|
||||||
|
_self call ["callPhoneBool", ["phone:contacts:add", [_uid, _fieldCommanderUid]]];
|
||||||
_self call ["refreshContacts", [_uid]];
|
_self call ["refreshContacts", [_uid]];
|
||||||
true
|
true
|
||||||
}],
|
}],
|
||||||
@ -59,11 +62,6 @@ GVAR(ContactStore) = createHashMapObject [[
|
|||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
if (_uid isEqualTo _contactUid) exitWith {
|
|
||||||
diag_log "[FORGE:Server:Phone:Contact] Cannot add self as contact";
|
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
private _added = _self call ["callPhoneBool", ["phone:contacts:add", [_uid, _contactUid]]];
|
private _added = _self call ["callPhoneBool", ["phone:contacts:add", [_uid, _contactUid]]];
|
||||||
if (_added) then { _self call ["refreshContacts", [_uid]]; };
|
if (_added) then { _self call ["refreshContacts", [_uid]]; };
|
||||||
_added
|
_added
|
||||||
@ -104,7 +102,6 @@ GVAR(ContactStore) = createHashMapObject [[
|
|||||||
private _matchedUid = "";
|
private _matchedUid = "";
|
||||||
{
|
{
|
||||||
private _candidateUid = _x;
|
private _candidateUid = _x;
|
||||||
if (_candidateUid isEqualTo _requesterUid) then { continue; };
|
|
||||||
|
|
||||||
private _actorValue = EGVAR(actor,ActorStore) call ["getFieldOrDefault", [_candidateUid, _field, ""]];
|
private _actorValue = EGVAR(actor,ActorStore) call ["getFieldOrDefault", [_candidateUid, _field, ""]];
|
||||||
if (_actorValue isEqualType "" && { toLowerANSI _actorValue isEqualTo _normalizedValue }) exitWith {
|
if (_actorValue isEqualType "" && { toLowerANSI _actorValue isEqualTo _normalizedValue }) exitWith {
|
||||||
@ -151,8 +148,31 @@ GVAR(ContactStore) = createHashMapObject [[
|
|||||||
};
|
};
|
||||||
|
|
||||||
private _contactObjects = [];
|
private _contactObjects = [];
|
||||||
|
private _fieldCommanderUid = "field_commander";
|
||||||
|
private _fieldCommanderContact = createHashMapFromArray [
|
||||||
|
["uid", _fieldCommanderUid],
|
||||||
|
["name", "Field Cmdr"],
|
||||||
|
["fullName", "Field Commander"],
|
||||||
|
["phone", "0160000000"],
|
||||||
|
["email", "field_cmdr@spearnet.mil"],
|
||||||
|
["online", false],
|
||||||
|
["system", true],
|
||||||
|
["canCall", false],
|
||||||
|
["canMessage", false],
|
||||||
|
["canEmail", false]
|
||||||
|
];
|
||||||
|
private _contactUids = _self call ["getContacts", [_uid]];
|
||||||
|
if !(_fieldCommanderUid in _contactUids) then {
|
||||||
|
_contactUids pushBack _fieldCommanderUid;
|
||||||
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
private _contactUid = _x;
|
private _contactUid = _x;
|
||||||
|
if (_contactUid isEqualTo _fieldCommanderUid) then {
|
||||||
|
_contactObjects pushBack _fieldCommanderContact;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
private _contactData = EGVAR(actor,ActorStore) call ["load", [_contactUid]];
|
private _contactData = EGVAR(actor,ActorStore) call ["load", [_contactUid]];
|
||||||
|
|
||||||
if (_contactData isNotEqualTo createHashMap) then {
|
if (_contactData isNotEqualTo createHashMap) then {
|
||||||
@ -165,12 +185,17 @@ GVAR(ContactStore) = createHashMapObject [[
|
|||||||
_contactObjects pushBack createHashMapFromArray [
|
_contactObjects pushBack createHashMapFromArray [
|
||||||
["uid", _contactUid],
|
["uid", _contactUid],
|
||||||
["name", _name],
|
["name", _name],
|
||||||
|
["fullName", _name],
|
||||||
["phone", _contactData getOrDefault ["phone_number", ""]],
|
["phone", _contactData getOrDefault ["phone_number", ""]],
|
||||||
["email", _contactData getOrDefault ["email", ""]],
|
["email", _contactData getOrDefault ["email", ""]],
|
||||||
["online", _isOnline]
|
["online", _isOnline],
|
||||||
|
["system", false],
|
||||||
|
["canCall", true],
|
||||||
|
["canMessage", true],
|
||||||
|
["canEmail", true]
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
} forEach (_self call ["getContacts", [_uid]]);
|
} forEach _contactUids;
|
||||||
|
|
||||||
private _player = [_uid] call EFUNC(common,getPlayer);
|
private _player = [_uid] call EFUNC(common,getPlayer);
|
||||||
if (!isNull _player) then {
|
if (!isNull _player) then {
|
||||||
|
|||||||
@ -65,8 +65,9 @@ GVAR(EmailStore) = createHashMapObject [[
|
|||||||
}],
|
}],
|
||||||
["sendEmail", {
|
["sendEmail", {
|
||||||
params [["_fromUid", "", [""]], ["_toUid", "", [""]], ["_subject", "", [""]], ["_body", "", [""]]];
|
params [["_fromUid", "", [""]], ["_toUid", "", [""]], ["_subject", "", [""]], ["_body", "", [""]]];
|
||||||
|
if (_subject isEqualTo "") then { _subject = "No subject"; };
|
||||||
|
|
||||||
if (_fromUid isEqualTo "" || { _toUid isEqualTo "" } || { _subject isEqualTo "" } || { _body isEqualTo "" }) exitWith {
|
if (_fromUid isEqualTo "" || { _toUid isEqualTo "" } || { _body isEqualTo "" }) exitWith {
|
||||||
diag_log "[FORGE:Server:Phone:Email] Invalid parameters provided to sendEmail";
|
diag_log "[FORGE:Server:Phone:Email] Invalid parameters provided to sendEmail";
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
@ -84,7 +85,9 @@ GVAR(EmailStore) = createHashMapObject [[
|
|||||||
_self call ["callPhoneBool", ["phone:emails:mark_read", [_uid, _emailId]]]
|
_self call ["callPhoneBool", ["phone:emails:mark_read", [_uid, _emailId]]]
|
||||||
}],
|
}],
|
||||||
["deleteEmail", {
|
["deleteEmail", {
|
||||||
false
|
params [["_uid", "", [""]], ["_emailId", "", [""]]];
|
||||||
|
if (_uid isEqualTo "" || { _emailId isEqualTo "" }) exitWith { false };
|
||||||
|
_self call ["callPhoneBool", ["phone:emails:delete", [_uid, _emailId]]]
|
||||||
}],
|
}],
|
||||||
["remove", {
|
["remove", {
|
||||||
params [["_uid", "", [""]]];
|
params [["_uid", "", [""]]];
|
||||||
|
|||||||
@ -83,6 +83,11 @@ GVAR(MessageStore) = createHashMapObject [[
|
|||||||
if (_uid isEqualTo "" || { _messageId isEqualTo "" }) exitWith { false };
|
if (_uid isEqualTo "" || { _messageId isEqualTo "" }) exitWith { false };
|
||||||
_self call ["callPhoneBool", ["phone:messages:mark_read", [_uid, _messageId]]]
|
_self call ["callPhoneBool", ["phone:messages:mark_read", [_uid, _messageId]]]
|
||||||
}],
|
}],
|
||||||
|
["deleteMessage", {
|
||||||
|
params [["_uid", "", [""]], ["_messageId", "", [""]]];
|
||||||
|
if (_uid isEqualTo "" || { _messageId isEqualTo "" }) exitWith { false };
|
||||||
|
_self call ["callPhoneBool", ["phone:messages:delete", [_uid, _messageId]]]
|
||||||
|
}],
|
||||||
["getMessages", {
|
["getMessages", {
|
||||||
params [["_uid", "", [""]]];
|
params [["_uid", "", [""]]];
|
||||||
if (_uid isEqualTo "") exitWith { [] };
|
if (_uid isEqualTo "") exitWith { [] };
|
||||||
|
|||||||
@ -114,6 +114,10 @@ GVAR(PhoneStore) = createHashMapObject [[
|
|||||||
params [["_uid", "", [""]], ["_messageId", "", [""]]];
|
params [["_uid", "", [""]], ["_messageId", "", [""]]];
|
||||||
GVAR(MessageStore) call ["markMessageRead", [_uid, _messageId]]
|
GVAR(MessageStore) call ["markMessageRead", [_uid, _messageId]]
|
||||||
}],
|
}],
|
||||||
|
["deleteMessage", {
|
||||||
|
params [["_uid", "", [""]], ["_messageId", "", [""]]];
|
||||||
|
GVAR(MessageStore) call ["deleteMessage", [_uid, _messageId]]
|
||||||
|
}],
|
||||||
["syncMessageIndices", {
|
["syncMessageIndices", {
|
||||||
params [["_uid", "", [""]]];
|
params [["_uid", "", [""]]];
|
||||||
GVAR(MessageStore) call ["syncMessageIndices", [_uid]]
|
GVAR(MessageStore) call ["syncMessageIndices", [_uid]]
|
||||||
@ -130,6 +134,10 @@ GVAR(PhoneStore) = createHashMapObject [[
|
|||||||
params [["_uid", "", [""]], ["_emailId", "", [""]]];
|
params [["_uid", "", [""]], ["_emailId", "", [""]]];
|
||||||
GVAR(EmailStore) call ["markEmailRead", [_uid, _emailId]]
|
GVAR(EmailStore) call ["markEmailRead", [_uid, _emailId]]
|
||||||
}],
|
}],
|
||||||
|
["deleteEmail", {
|
||||||
|
params [["_uid", "", [""]], ["_emailId", "", [""]]];
|
||||||
|
GVAR(EmailStore) call ["deleteEmail", [_uid, _emailId]]
|
||||||
|
}],
|
||||||
["remove", {
|
["remove", {
|
||||||
params [["_uid", "", [""]]];
|
params [["_uid", "", [""]]];
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ crate-type = ["cdylib"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
arma-rs = { workspace = true }
|
arma-rs = { workspace = true }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
bb8-redis = "0.25.0-rc.1"
|
bb8-redis = "0.26.0"
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
forge-icom = { path = "../../../bin/icom" }
|
forge-icom = { path = "../../../bin/icom" }
|
||||||
forge-models = { path = "../../../lib/models", features = ["actor"] }
|
forge-models = { path = "../../../lib/models", features = ["actor"] }
|
||||||
@ -20,6 +20,7 @@ forge-shared = { path = "../../../lib/shared" }
|
|||||||
redis = { workspace = true }
|
redis = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
surrealdb = { version = "2", default-features = false, features = ["protocol-http", "rustls"] }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
toml = "0.9.8"
|
toml = "1.1.2"
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|||||||
@ -2,6 +2,12 @@
|
|||||||
# Copy this file to config.toml and modify as needed
|
# Copy this file to config.toml and modify as needed
|
||||||
# Place this file in the same directory as your crate_server_x64.dll
|
# Place this file in the same directory as your crate_server_x64.dll
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
# Redis remains the default while modules are migrated incrementally.
|
||||||
|
# Current SurrealDB-backed durable repositories:
|
||||||
|
# actor, bank, garage, locker, owned garage, owned locker, org, phone.
|
||||||
|
backend = "redis" # "redis" or "surreal"
|
||||||
|
|
||||||
[redis]
|
[redis]
|
||||||
# Redis server connection settings
|
# Redis server connection settings
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
@ -20,6 +26,18 @@ connect_timeout_ms = 2000 # Pool connect timeout in milliseconds
|
|||||||
pool_get_timeout_ms = 2000 # Pool checkout timeout in milliseconds
|
pool_get_timeout_ms = 2000 # Pool checkout timeout in milliseconds
|
||||||
command_timeout_ms = 2000 # Redis command timeout in milliseconds
|
command_timeout_ms = 2000 # Redis command timeout in milliseconds
|
||||||
|
|
||||||
|
[surreal]
|
||||||
|
# SurrealDB HTTP endpoint. Use "127.0.0.1:8000" for a local SurrealDB server.
|
||||||
|
endpoint = "127.0.0.1:8000"
|
||||||
|
namespace = "forge"
|
||||||
|
database = "main"
|
||||||
|
|
||||||
|
# Optional authentication
|
||||||
|
username = "root"
|
||||||
|
password = "root"
|
||||||
|
|
||||||
|
connect_timeout_ms = 5000
|
||||||
|
|
||||||
# Example configurations for different environments:
|
# Example configurations for different environments:
|
||||||
|
|
||||||
# Development (local Redis)
|
# Development (local Redis)
|
||||||
|
|||||||
@ -4,38 +4,31 @@
|
|||||||
//! Handles SQF command mapping and parameter validation.
|
//! Handles SQF command mapping and parameter validation.
|
||||||
|
|
||||||
use arma_rs::{CallContext, Group};
|
use arma_rs::{CallContext, Group};
|
||||||
use forge_repositories::{InMemoryActorHotRepository, RedisActorRepository};
|
use forge_repositories::InMemoryActorHotRepository;
|
||||||
use forge_services::{ActorHotStateService, ActorService};
|
use forge_services::{ActorHotStateService, ActorService};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use crate::adapters::ExtensionRedisClient;
|
|
||||||
use crate::enqueue_persistence_task;
|
use crate::enqueue_persistence_task;
|
||||||
use crate::helpers::resolve_uid;
|
use crate::helpers::resolve_uid;
|
||||||
use crate::log::log;
|
use crate::log::log;
|
||||||
|
use crate::storage::ActorStorageRepository;
|
||||||
|
|
||||||
/// Global actor service instance.
|
/// Global actor service instance.
|
||||||
///
|
///
|
||||||
/// Lazily initialized singleton combining Redis adapter, repository, and service layers.
|
/// Lazily initialized singleton combining Redis adapter, repository, and service layers.
|
||||||
static ACTOR_SERVICE: LazyLock<ActorService<RedisActorRepository<ExtensionRedisClient>>> =
|
static ACTOR_SERVICE: LazyLock<ActorService<ActorStorageRepository>> =
|
||||||
LazyLock::new(|| {
|
LazyLock::new(|| ActorService::new(ActorStorageRepository::configured()));
|
||||||
let redis_client = ExtensionRedisClient::new();
|
|
||||||
let repository = RedisActorRepository::new(redis_client);
|
|
||||||
ActorService::new(repository)
|
|
||||||
});
|
|
||||||
static HOT_ACTOR_SERVICE: LazyLock<
|
static HOT_ACTOR_SERVICE: LazyLock<
|
||||||
ActorHotStateService<RedisActorRepository<ExtensionRedisClient>, InMemoryActorHotRepository>,
|
ActorHotStateService<ActorStorageRepository, InMemoryActorHotRepository>,
|
||||||
> = LazyLock::new(|| {
|
> = LazyLock::new(|| {
|
||||||
let redis_client = ExtensionRedisClient::new();
|
let repository = ActorStorageRepository::configured();
|
||||||
let repository = RedisActorRepository::new(redis_client);
|
|
||||||
let hot_repository = InMemoryActorHotRepository::new();
|
let hot_repository = InMemoryActorHotRepository::new();
|
||||||
ActorHotStateService::new(repository, hot_repository)
|
ActorHotStateService::new(repository, hot_repository)
|
||||||
});
|
});
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub(crate) fn hot_service() -> &'static ActorHotStateService<
|
pub(crate) fn hot_service()
|
||||||
RedisActorRepository<ExtensionRedisClient>,
|
-> &'static ActorHotStateService<ActorStorageRepository, InMemoryActorHotRepository> {
|
||||||
InMemoryActorHotRepository,
|
|
||||||
> {
|
|
||||||
&HOT_ACTOR_SERVICE
|
&HOT_ACTOR_SERVICE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,37 +8,30 @@ use forge_models::{
|
|||||||
BankCheckoutContext, BankMutationResult, BankOperationContext, BankPinContext,
|
BankCheckoutContext, BankMutationResult, BankOperationContext, BankPinContext,
|
||||||
BankTransferContext, BankTransferResult,
|
BankTransferContext, BankTransferResult,
|
||||||
};
|
};
|
||||||
use forge_repositories::{InMemoryBankHotRepository, RedisBankRepository};
|
use forge_repositories::InMemoryBankHotRepository;
|
||||||
use forge_services::{BankHotStateService, BankService};
|
use forge_services::{BankHotStateService, BankService};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use crate::adapters::ExtensionRedisClient;
|
|
||||||
use crate::enqueue_persistence_task;
|
use crate::enqueue_persistence_task;
|
||||||
use crate::helpers::resolve_uid;
|
use crate::helpers::resolve_uid;
|
||||||
use crate::log::log;
|
use crate::log::log;
|
||||||
|
use crate::storage::BankStorageRepository;
|
||||||
|
|
||||||
/// Global bank service instance.
|
/// Global bank service instance.
|
||||||
///
|
///
|
||||||
/// Lazily initialized singleton combining Redis adapter, repository, and service layers.
|
/// Lazily initialized singleton combining Redis adapter, repository, and service layers.
|
||||||
static BANK_SERVICE: LazyLock<BankService<RedisBankRepository<ExtensionRedisClient>>> =
|
static BANK_SERVICE: LazyLock<BankService<BankStorageRepository>> =
|
||||||
LazyLock::new(|| {
|
LazyLock::new(|| BankService::new(BankStorageRepository::configured()));
|
||||||
let redis_client = ExtensionRedisClient::new();
|
|
||||||
let repository = RedisBankRepository::new(redis_client);
|
|
||||||
BankService::new(repository)
|
|
||||||
});
|
|
||||||
static HOT_BANK_SERVICE: LazyLock<
|
static HOT_BANK_SERVICE: LazyLock<
|
||||||
BankHotStateService<RedisBankRepository<ExtensionRedisClient>, InMemoryBankHotRepository>,
|
BankHotStateService<BankStorageRepository, InMemoryBankHotRepository>,
|
||||||
> = LazyLock::new(|| {
|
> = LazyLock::new(|| {
|
||||||
let redis_client = ExtensionRedisClient::new();
|
let repository = BankStorageRepository::configured();
|
||||||
let repository = RedisBankRepository::new(redis_client);
|
|
||||||
let hot_repository = InMemoryBankHotRepository::new();
|
let hot_repository = InMemoryBankHotRepository::new();
|
||||||
BankHotStateService::new(repository, hot_repository)
|
BankHotStateService::new(repository, hot_repository)
|
||||||
});
|
});
|
||||||
|
|
||||||
pub(crate) fn hot_service() -> &'static BankHotStateService<
|
pub(crate) fn hot_service()
|
||||||
RedisBankRepository<ExtensionRedisClient>,
|
-> &'static BankHotStateService<BankStorageRepository, InMemoryBankHotRepository> {
|
||||||
InMemoryBankHotRepository,
|
|
||||||
> {
|
|
||||||
&HOT_BANK_SERVICE
|
&HOT_BANK_SERVICE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,37 +4,30 @@
|
|||||||
|
|
||||||
use arma_rs::{CallContext, Group};
|
use arma_rs::{CallContext, Group};
|
||||||
use forge_models::Vehicle;
|
use forge_models::Vehicle;
|
||||||
use forge_repositories::{InMemoryGarageHotRepository, RedisGarageRepository};
|
use forge_repositories::InMemoryGarageHotRepository;
|
||||||
use forge_services::{GarageHotStateService, GarageService};
|
use forge_services::{GarageHotStateService, GarageService};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use crate::adapters::ExtensionRedisClient;
|
|
||||||
use crate::enqueue_persistence_task;
|
use crate::enqueue_persistence_task;
|
||||||
use crate::helpers::resolve_uid;
|
use crate::helpers::resolve_uid;
|
||||||
use crate::log::log;
|
use crate::log::log;
|
||||||
|
use crate::storage::GarageStorageRepository;
|
||||||
|
|
||||||
/// Global garage service instance.
|
/// Global garage service instance.
|
||||||
static GARAGE_SERVICE: LazyLock<GarageService<RedisGarageRepository<ExtensionRedisClient>>> =
|
static GARAGE_SERVICE: LazyLock<GarageService<GarageStorageRepository>> =
|
||||||
LazyLock::new(|| {
|
LazyLock::new(|| GarageService::new(GarageStorageRepository::configured()));
|
||||||
let redis_client = ExtensionRedisClient::new();
|
|
||||||
let repository = RedisGarageRepository::new(redis_client);
|
|
||||||
GarageService::new(repository)
|
|
||||||
});
|
|
||||||
static HOT_GARAGE_SERVICE: LazyLock<
|
static HOT_GARAGE_SERVICE: LazyLock<
|
||||||
GarageHotStateService<RedisGarageRepository<ExtensionRedisClient>, InMemoryGarageHotRepository>,
|
GarageHotStateService<GarageStorageRepository, InMemoryGarageHotRepository>,
|
||||||
> = LazyLock::new(|| {
|
> = LazyLock::new(|| {
|
||||||
let redis_client = ExtensionRedisClient::new();
|
let repository = GarageStorageRepository::configured();
|
||||||
let repository = RedisGarageRepository::new(redis_client);
|
|
||||||
let hot_repository = InMemoryGarageHotRepository::new();
|
let hot_repository = InMemoryGarageHotRepository::new();
|
||||||
GarageHotStateService::new(repository, hot_repository)
|
GarageHotStateService::new(repository, hot_repository)
|
||||||
});
|
});
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub(crate) fn hot_service() -> &'static GarageHotStateService<
|
pub(crate) fn hot_service()
|
||||||
RedisGarageRepository<ExtensionRedisClient>,
|
-> &'static GarageHotStateService<GarageStorageRepository, InMemoryGarageHotRepository> {
|
||||||
InMemoryGarageHotRepository,
|
|
||||||
> {
|
|
||||||
&HOT_GARAGE_SERVICE
|
&HOT_GARAGE_SERVICE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -23,7 +23,9 @@ mod log;
|
|||||||
pub mod org;
|
pub mod org;
|
||||||
pub mod phone;
|
pub mod phone;
|
||||||
pub mod redis;
|
pub mod redis;
|
||||||
|
pub mod storage;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
pub mod surreal;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
pub mod terrain;
|
pub mod terrain;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
@ -77,10 +79,12 @@ where
|
|||||||
/// creates the Redis connection pool on the global runtime.
|
/// creates the Redis connection pool on the global runtime.
|
||||||
fn init() -> Extension {
|
fn init() -> Extension {
|
||||||
let config = redis::config::load();
|
let config = redis::config::load();
|
||||||
|
let storage_backend = config.storage.backend;
|
||||||
let ext = Extension::build()
|
let ext = Extension::build()
|
||||||
.command("version", get_version)
|
.command("version", get_version)
|
||||||
.command("status", get_status)
|
.command("status", get_status)
|
||||||
.group("redis", redis::group())
|
.group("redis", redis::group())
|
||||||
|
.group("surreal", surreal::group())
|
||||||
.group("actor", actor::group())
|
.group("actor", actor::group())
|
||||||
.group("bank", bank::group())
|
.group("bank", bank::group())
|
||||||
.group("cad", cad::group())
|
.group("cad", cad::group())
|
||||||
@ -104,6 +108,14 @@ fn init() -> Extension {
|
|||||||
// Spawn initialization tasks for Redis and ICOM
|
// Spawn initialization tasks for Redis and ICOM
|
||||||
// These run asynchronously and don't block extension startup
|
// These run asynchronously and don't block extension startup
|
||||||
// Redis initialization will set the global CONTEXT
|
// Redis initialization will set the global CONTEXT
|
||||||
|
if storage_backend == redis::config::StorageBackend::Surreal {
|
||||||
|
let surreal_config = config.surreal.clone();
|
||||||
|
surreal::prepare();
|
||||||
|
RUNTIME.spawn(async move {
|
||||||
|
surreal::initialize(surreal_config).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
RUNTIME.spawn(async move {
|
RUNTIME.spawn(async move {
|
||||||
redis::initialize(config.redis).await;
|
redis::initialize(config.redis).await;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,34 +1,27 @@
|
|||||||
use arma_rs::{CallContext, Group};
|
use arma_rs::{CallContext, Group};
|
||||||
use forge_models::locker::Item;
|
use forge_models::locker::Item;
|
||||||
use forge_repositories::{InMemoryLockerHotRepository, RedisLockerRepository};
|
use forge_repositories::InMemoryLockerHotRepository;
|
||||||
use forge_services::{LockerHotStateService, LockerService};
|
use forge_services::{LockerHotStateService, LockerService};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use crate::adapters::ExtensionRedisClient;
|
|
||||||
use crate::enqueue_persistence_task;
|
use crate::enqueue_persistence_task;
|
||||||
use crate::helpers::resolve_uid;
|
use crate::helpers::resolve_uid;
|
||||||
use crate::log::log;
|
use crate::log::log;
|
||||||
|
use crate::storage::LockerStorageRepository;
|
||||||
|
|
||||||
static LOCKER_SERVICE: LazyLock<LockerService<RedisLockerRepository<ExtensionRedisClient>>> =
|
static LOCKER_SERVICE: LazyLock<LockerService<LockerStorageRepository>> =
|
||||||
LazyLock::new(|| {
|
LazyLock::new(|| LockerService::new(LockerStorageRepository::configured()));
|
||||||
let redis_client = ExtensionRedisClient::new();
|
|
||||||
let repository = RedisLockerRepository::new(redis_client);
|
|
||||||
LockerService::new(repository)
|
|
||||||
});
|
|
||||||
static HOT_LOCKER_SERVICE: LazyLock<
|
static HOT_LOCKER_SERVICE: LazyLock<
|
||||||
LockerHotStateService<RedisLockerRepository<ExtensionRedisClient>, InMemoryLockerHotRepository>,
|
LockerHotStateService<LockerStorageRepository, InMemoryLockerHotRepository>,
|
||||||
> = LazyLock::new(|| {
|
> = LazyLock::new(|| {
|
||||||
let redis_client = ExtensionRedisClient::new();
|
let repository = LockerStorageRepository::configured();
|
||||||
let repository = RedisLockerRepository::new(redis_client);
|
|
||||||
let hot_repository = InMemoryLockerHotRepository::new();
|
let hot_repository = InMemoryLockerHotRepository::new();
|
||||||
LockerHotStateService::new(repository, hot_repository)
|
LockerHotStateService::new(repository, hot_repository)
|
||||||
});
|
});
|
||||||
|
|
||||||
pub(crate) fn hot_service() -> &'static LockerHotStateService<
|
pub(crate) fn hot_service()
|
||||||
RedisLockerRepository<ExtensionRedisClient>,
|
-> &'static LockerHotStateService<LockerStorageRepository, InMemoryLockerHotRepository> {
|
||||||
InMemoryLockerHotRepository,
|
|
||||||
> {
|
|
||||||
&HOT_LOCKER_SERVICE
|
&HOT_LOCKER_SERVICE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,34 +11,29 @@ use forge_models::{
|
|||||||
OrgInviteDecisionContext, OrgInviteDecisionResult, OrgInviteRecord, OrgInviteResult,
|
OrgInviteDecisionContext, OrgInviteDecisionResult, OrgInviteRecord, OrgInviteResult,
|
||||||
OrgLeaveContext, OrgLeaveResult, OrgRegisterContext,
|
OrgLeaveContext, OrgLeaveResult, OrgRegisterContext,
|
||||||
};
|
};
|
||||||
use forge_repositories::{InMemoryOrgHotRepository, RedisOrgRepository};
|
use forge_repositories::InMemoryOrgHotRepository;
|
||||||
use forge_services::{OrgHotStateService, OrgService};
|
use forge_services::{OrgHotStateService, OrgService};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use crate::adapters::ExtensionRedisClient;
|
|
||||||
use crate::enqueue_persistence_task;
|
use crate::enqueue_persistence_task;
|
||||||
use crate::log::log;
|
use crate::log::log;
|
||||||
|
use crate::storage::OrgStorageRepository;
|
||||||
|
|
||||||
/// Global organization service instance.
|
/// Global organization service instance.
|
||||||
///
|
///
|
||||||
/// Lazily initialized singleton combining Redis adapter, repository, and service layers.
|
/// Lazily initialized singleton combining Redis adapter, repository, and service layers.
|
||||||
static ORG_SERVICE: LazyLock<OrgService<RedisOrgRepository<ExtensionRedisClient>>> =
|
static ORG_SERVICE: LazyLock<OrgService<OrgStorageRepository>> =
|
||||||
LazyLock::new(|| {
|
LazyLock::new(|| OrgService::new(OrgStorageRepository::configured()));
|
||||||
let redis_client = ExtensionRedisClient::new();
|
|
||||||
let repository = RedisOrgRepository::new(redis_client);
|
|
||||||
OrgService::new(repository)
|
|
||||||
});
|
|
||||||
static HOT_ORG_SERVICE: LazyLock<
|
static HOT_ORG_SERVICE: LazyLock<
|
||||||
OrgHotStateService<RedisOrgRepository<ExtensionRedisClient>, InMemoryOrgHotRepository>,
|
OrgHotStateService<OrgStorageRepository, InMemoryOrgHotRepository>,
|
||||||
> = LazyLock::new(|| {
|
> = LazyLock::new(|| {
|
||||||
let redis_client = ExtensionRedisClient::new();
|
let repository = OrgStorageRepository::configured();
|
||||||
let repository = RedisOrgRepository::new(redis_client);
|
|
||||||
let hot_repository = InMemoryOrgHotRepository::new();
|
let hot_repository = InMemoryOrgHotRepository::new();
|
||||||
OrgHotStateService::new(repository, hot_repository)
|
OrgHotStateService::new(repository, hot_repository)
|
||||||
});
|
});
|
||||||
|
|
||||||
pub(crate) fn hot_service()
|
pub(crate) fn hot_service()
|
||||||
-> &'static OrgHotStateService<RedisOrgRepository<ExtensionRedisClient>, InMemoryOrgHotRepository> {
|
-> &'static OrgHotStateService<OrgStorageRepository, InMemoryOrgHotRepository> {
|
||||||
&HOT_ORG_SERVICE
|
&HOT_ORG_SERVICE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,17 +3,14 @@
|
|||||||
//! The extension owns phone runtime state for contacts, messages, and emails.
|
//! The extension owns phone runtime state for contacts, messages, and emails.
|
||||||
//! SQF remains the event bridge and may enrich contact identity from actor state.
|
//! SQF remains the event bridge and may enrich contact identity from actor state.
|
||||||
|
|
||||||
use crate::adapters::ExtensionRedisClient;
|
use crate::storage::PhoneStorageRepository;
|
||||||
use arma_rs::Group;
|
use arma_rs::Group;
|
||||||
use forge_repositories::RedisPhoneRepository;
|
|
||||||
use forge_services::PhoneStateService;
|
use forge_services::PhoneStateService;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
static PHONE_SERVICE: LazyLock<PhoneStateService<RedisPhoneRepository<ExtensionRedisClient>>> =
|
static PHONE_SERVICE: LazyLock<PhoneStateService<PhoneStorageRepository>> =
|
||||||
LazyLock::new(|| {
|
LazyLock::new(|| PhoneStateService::new(PhoneStorageRepository::configured()));
|
||||||
PhoneStateService::new(RedisPhoneRepository::new(ExtensionRedisClient::new()))
|
|
||||||
});
|
|
||||||
|
|
||||||
pub fn group() -> Group {
|
pub fn group() -> Group {
|
||||||
Group::new()
|
Group::new()
|
||||||
@ -31,14 +28,16 @@ pub fn group() -> Group {
|
|||||||
.command("list", list_messages)
|
.command("list", list_messages)
|
||||||
.command("thread", message_thread)
|
.command("thread", message_thread)
|
||||||
.command("send", send_message)
|
.command("send", send_message)
|
||||||
.command("mark_read", mark_message_read),
|
.command("mark_read", mark_message_read)
|
||||||
|
.command("delete", delete_message),
|
||||||
)
|
)
|
||||||
.group(
|
.group(
|
||||||
"emails",
|
"emails",
|
||||||
Group::new()
|
Group::new()
|
||||||
.command("list", list_emails)
|
.command("list", list_emails)
|
||||||
.command("send", send_email)
|
.command("send", send_email)
|
||||||
.command("mark_read", mark_email_read),
|
.command("mark_read", mark_email_read)
|
||||||
|
.command("delete", delete_email),
|
||||||
)
|
)
|
||||||
.command("remove", remove_phone)
|
.command("remove", remove_phone)
|
||||||
}
|
}
|
||||||
@ -80,6 +79,10 @@ pub(crate) fn mark_message_read(uid: String, message_id: String) -> String {
|
|||||||
serialize_bool(PHONE_SERVICE.mark_message_read(uid, message_id))
|
serialize_bool(PHONE_SERVICE.mark_message_read(uid, message_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn delete_message(uid: String, message_id: String) -> String {
|
||||||
|
serialize_bool(PHONE_SERVICE.delete_message(uid, message_id))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn send_email(
|
pub(crate) fn send_email(
|
||||||
from_uid: String,
|
from_uid: String,
|
||||||
to_uid: String,
|
to_uid: String,
|
||||||
@ -98,6 +101,10 @@ pub(crate) fn mark_email_read(uid: String, email_id: String) -> String {
|
|||||||
serialize_bool(PHONE_SERVICE.mark_email_read(uid, email_id))
|
serialize_bool(PHONE_SERVICE.mark_email_read(uid, email_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn delete_email(uid: String, email_id: String) -> String {
|
||||||
|
serialize_bool(PHONE_SERVICE.delete_email(uid, email_id))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn remove_phone(uid: String) -> String {
|
pub(crate) fn remove_phone(uid: String) -> String {
|
||||||
match PHONE_SERVICE.remove(uid) {
|
match PHONE_SERVICE.remove(uid) {
|
||||||
Ok(()) => "OK".to_string(),
|
Ok(()) => "OK".to_string(),
|
||||||
|
|||||||
@ -12,16 +12,52 @@ static CONFIG_CACHE: OnceLock<Config> = OnceLock::new();
|
|||||||
/// Main configuration structure for the entire application.
|
/// Main configuration structure for the entire application.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
/// Durable storage backend selector.
|
||||||
|
#[serde(default)]
|
||||||
|
pub storage: StorageConfig,
|
||||||
/// Redis configuration with automatic defaults if not specified
|
/// Redis configuration with automatic defaults if not specified
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub redis: RedisConfig,
|
pub redis: RedisConfig,
|
||||||
|
/// SurrealDB configuration with automatic defaults if not specified
|
||||||
|
#[serde(default)]
|
||||||
|
pub surreal: SurrealConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
/// Creates a default configuration with sensible values for development.
|
/// Creates a default configuration with sensible values for development.
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
storage: StorageConfig::default(),
|
||||||
redis: RedisConfig::default(),
|
redis: RedisConfig::default(),
|
||||||
|
surreal: SurrealConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum StorageBackend {
|
||||||
|
Redis,
|
||||||
|
Surreal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StorageBackend {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Redis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durable storage backend selection.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct StorageConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub backend: StorageBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StorageConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
backend: StorageBackend::Redis,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -72,6 +108,36 @@ impl Default for RedisConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SurrealDB connection configuration.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct SurrealConfig {
|
||||||
|
/// SurrealDB HTTP endpoint, for example `127.0.0.1:8000`.
|
||||||
|
pub endpoint: String,
|
||||||
|
/// SurrealDB namespace.
|
||||||
|
pub namespace: String,
|
||||||
|
/// SurrealDB database.
|
||||||
|
pub database: String,
|
||||||
|
/// Optional root username for authentication.
|
||||||
|
pub username: Option<String>,
|
||||||
|
/// Optional root password for authentication.
|
||||||
|
pub password: Option<String>,
|
||||||
|
/// Maximum time to wait for initial connection in milliseconds.
|
||||||
|
pub connect_timeout_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SurrealConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
endpoint: "127.0.0.1:8000".to_string(),
|
||||||
|
namespace: "forge".to_string(),
|
||||||
|
database: "main".to_string(),
|
||||||
|
username: Some("root".to_string()),
|
||||||
|
password: Some("root".to_string()),
|
||||||
|
connect_timeout_ms: Some(5000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl RedisConfig {
|
impl RedisConfig {
|
||||||
/// Generates a Redis connection string from the configuration.
|
/// Generates a Redis connection string from the configuration.
|
||||||
pub fn connection_string(&self) -> String {
|
pub fn connection_string(&self) -> String {
|
||||||
@ -121,7 +187,18 @@ pub fn load() -> Config {
|
|||||||
log("main", "INFO", &format!("Config file found! Loading..."));
|
log("main", "INFO", &format!("Config file found! Loading..."));
|
||||||
match toml::from_str::<Config>(&contents) {
|
match toml::from_str::<Config>(&contents) {
|
||||||
Ok(config) => config,
|
Ok(config) => config,
|
||||||
Err(_) => Config::default(),
|
Err(error) => {
|
||||||
|
log(
|
||||||
|
"main",
|
||||||
|
"ERROR",
|
||||||
|
&format!(
|
||||||
|
"Failed to parse config file '{}': {}. Using defaults.",
|
||||||
|
config_path.display(),
|
||||||
|
error
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Config::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
|||||||
1338
arma/server/extension/src/storage.rs
Normal file
1338
arma/server/extension/src/storage.rs
Normal file
File diff suppressed because it is too large
Load Diff
170
arma/server/extension/src/surreal.rs
Normal file
170
arma/server/extension/src/surreal.rs
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
//! SurrealDB connection bootstrap for persistent storage.
|
||||||
|
|
||||||
|
use arma_rs::Group;
|
||||||
|
use std::sync::{LazyLock, OnceLock, RwLock as StdRwLock};
|
||||||
|
use surrealdb::Surreal;
|
||||||
|
use surrealdb::engine::remote::http::{Client, Http};
|
||||||
|
use surrealdb::opt::auth::Root;
|
||||||
|
use tokio::time::{Duration, sleep, timeout};
|
||||||
|
|
||||||
|
use crate::log;
|
||||||
|
use crate::redis::config::SurrealConfig;
|
||||||
|
|
||||||
|
pub type SurrealDb = Surreal<Client>;
|
||||||
|
|
||||||
|
const CLIENT_READY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const CLIENT_READY_POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||||
|
|
||||||
|
static SURREAL_DB: OnceLock<SurrealDb> = OnceLock::new();
|
||||||
|
static SURREAL_CONNECTION_STATE: LazyLock<StdRwLock<SurrealConnectionState>> =
|
||||||
|
LazyLock::new(|| StdRwLock::new(SurrealConnectionState::Disabled));
|
||||||
|
static SURREAL_FAILURE_REASON: LazyLock<StdRwLock<Option<String>>> =
|
||||||
|
LazyLock::new(|| StdRwLock::new(None));
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum SurrealConnectionState {
|
||||||
|
Disabled,
|
||||||
|
Initializing,
|
||||||
|
Connected,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepare() {
|
||||||
|
*SURREAL_FAILURE_REASON.write().unwrap() = None;
|
||||||
|
*SURREAL_CONNECTION_STATE.write().unwrap() = SurrealConnectionState::Initializing;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn initialize(config: SurrealConfig) {
|
||||||
|
prepare();
|
||||||
|
|
||||||
|
log::log(
|
||||||
|
"surreal",
|
||||||
|
"INFO",
|
||||||
|
&format!(
|
||||||
|
"Connecting to SurrealDB endpoint '{}' namespace '{}' database '{}'",
|
||||||
|
config.endpoint, config.namespace, config.database
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let timeout_duration = Duration::from_millis(config.connect_timeout_ms.unwrap_or(5000));
|
||||||
|
let connection = timeout(timeout_duration, connect(config)).await;
|
||||||
|
|
||||||
|
let db = match connection {
|
||||||
|
Err(_) => {
|
||||||
|
log::log(
|
||||||
|
"surreal",
|
||||||
|
"ERROR",
|
||||||
|
&format!(
|
||||||
|
"SurrealDB connection timed out after {} ms",
|
||||||
|
timeout_duration.as_millis()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
set_failure_reason(format!(
|
||||||
|
"SurrealDB connection timed out after {} ms",
|
||||||
|
timeout_duration.as_millis()
|
||||||
|
));
|
||||||
|
*SURREAL_CONNECTION_STATE.write().unwrap() = SurrealConnectionState::Failed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(Ok(db)) => db,
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
log::log(
|
||||||
|
"surreal",
|
||||||
|
"ERROR",
|
||||||
|
&format!("Failed to connect to SurrealDB: {}", error),
|
||||||
|
);
|
||||||
|
set_failure_reason(error);
|
||||||
|
*SURREAL_CONNECTION_STATE.write().unwrap() = SurrealConnectionState::Failed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if SURREAL_DB.set(db).is_ok() {
|
||||||
|
log::log("surreal", "INFO", "Connected to SurrealDB server");
|
||||||
|
*SURREAL_CONNECTION_STATE.write().unwrap() = SurrealConnectionState::Connected;
|
||||||
|
} else {
|
||||||
|
log::log("surreal", "ERROR", "Failed to set SurrealDB client");
|
||||||
|
set_failure_reason("Failed to set SurrealDB client".to_string());
|
||||||
|
*SURREAL_CONNECTION_STATE.write().unwrap() = SurrealConnectionState::Failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_failure_reason(reason: String) {
|
||||||
|
*SURREAL_FAILURE_REASON.write().unwrap() = Some(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure_reason() -> String {
|
||||||
|
SURREAL_FAILURE_REASON
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "unknown failure".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect(config: SurrealConfig) -> Result<SurrealDb, String> {
|
||||||
|
let db = Surreal::new::<Http>(config.endpoint.as_str())
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
|
if let (Some(username), Some(password)) = (&config.username, &config.password) {
|
||||||
|
db.signin(Root {
|
||||||
|
username: username.as_str(),
|
||||||
|
password: password.as_str(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.use_ns(config.namespace.as_str())
|
||||||
|
.use_db(config.database.as_str())
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn client() -> Result<&'static SurrealDb, String> {
|
||||||
|
if let Some(db) = SURREAL_DB.get() {
|
||||||
|
return Ok(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout(CLIENT_READY_TIMEOUT, wait_for_client())
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
Err("SurrealDB connection did not become ready before timeout".to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_client() -> Result<&'static SurrealDb, String> {
|
||||||
|
loop {
|
||||||
|
if let Some(db) = SURREAL_DB.get() {
|
||||||
|
return Ok(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
match *SURREAL_CONNECTION_STATE.read().unwrap() {
|
||||||
|
SurrealConnectionState::Disabled => {
|
||||||
|
return Err("SurrealDB connection is disabled".to_string());
|
||||||
|
}
|
||||||
|
SurrealConnectionState::Failed => {
|
||||||
|
return Err(format!("SurrealDB connection failed: {}", failure_reason()));
|
||||||
|
}
|
||||||
|
SurrealConnectionState::Initializing | SurrealConnectionState::Connected => {
|
||||||
|
sleep(CLIENT_READY_POLL_INTERVAL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status() -> String {
|
||||||
|
let state = *SURREAL_CONNECTION_STATE.read().unwrap();
|
||||||
|
match state {
|
||||||
|
SurrealConnectionState::Disabled => "disabled".to_string(),
|
||||||
|
SurrealConnectionState::Initializing => "initializing".to_string(),
|
||||||
|
SurrealConnectionState::Connected => "connected".to_string(),
|
||||||
|
SurrealConnectionState::Failed => "failed".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn group() -> Group {
|
||||||
|
Group::new().command("status", status)
|
||||||
|
}
|
||||||
@ -1,36 +1,26 @@
|
|||||||
use arma_rs::{CallContext, Group};
|
use arma_rs::{CallContext, Group};
|
||||||
use forge_models::{VGarage, VehicleCategory};
|
use forge_models::{VGarage, VehicleCategory};
|
||||||
use forge_repositories::{InMemoryVGarageHotRepository, RedisVGarageRepository};
|
use forge_repositories::InMemoryVGarageHotRepository;
|
||||||
use forge_services::{VGarageHotStateService, VGarageService};
|
use forge_services::{VGarageHotStateService, VGarageService};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use crate::adapters::ExtensionRedisClient;
|
|
||||||
use crate::enqueue_persistence_task;
|
use crate::enqueue_persistence_task;
|
||||||
use crate::helpers::resolve_uid;
|
use crate::helpers::resolve_uid;
|
||||||
use crate::log::log;
|
use crate::log::log;
|
||||||
|
use crate::storage::VGarageStorageRepository;
|
||||||
|
|
||||||
static VGARAGE_SERVICE: LazyLock<VGarageService<RedisVGarageRepository<ExtensionRedisClient>>> =
|
static VGARAGE_SERVICE: LazyLock<VGarageService<VGarageStorageRepository>> =
|
||||||
LazyLock::new(|| {
|
LazyLock::new(|| VGarageService::new(VGarageStorageRepository::configured()));
|
||||||
let redis_client = ExtensionRedisClient::new();
|
|
||||||
let repository = RedisVGarageRepository::new(redis_client);
|
|
||||||
VGarageService::new(repository)
|
|
||||||
});
|
|
||||||
static HOT_VGARAGE_SERVICE: LazyLock<
|
static HOT_VGARAGE_SERVICE: LazyLock<
|
||||||
VGarageHotStateService<
|
VGarageHotStateService<VGarageStorageRepository, InMemoryVGarageHotRepository>,
|
||||||
RedisVGarageRepository<ExtensionRedisClient>,
|
|
||||||
InMemoryVGarageHotRepository,
|
|
||||||
>,
|
|
||||||
> = LazyLock::new(|| {
|
> = LazyLock::new(|| {
|
||||||
let redis_client = ExtensionRedisClient::new();
|
let repository = VGarageStorageRepository::configured();
|
||||||
let repository = RedisVGarageRepository::new(redis_client);
|
|
||||||
let hot_repository = InMemoryVGarageHotRepository::new();
|
let hot_repository = InMemoryVGarageHotRepository::new();
|
||||||
VGarageHotStateService::new(repository, hot_repository)
|
VGarageHotStateService::new(repository, hot_repository)
|
||||||
});
|
});
|
||||||
|
|
||||||
pub(crate) fn hot_service() -> &'static VGarageHotStateService<
|
pub(crate) fn hot_service()
|
||||||
RedisVGarageRepository<ExtensionRedisClient>,
|
-> &'static VGarageHotStateService<VGarageStorageRepository, InMemoryVGarageHotRepository> {
|
||||||
InMemoryVGarageHotRepository,
|
|
||||||
> {
|
|
||||||
&HOT_VGARAGE_SERVICE
|
&HOT_VGARAGE_SERVICE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,36 +1,26 @@
|
|||||||
use arma_rs::{CallContext, Group};
|
use arma_rs::{CallContext, Group};
|
||||||
use forge_models::{EquipmentCategory, VLocker};
|
use forge_models::{EquipmentCategory, VLocker};
|
||||||
use forge_repositories::{InMemoryVLockerHotRepository, RedisVLockerRepository};
|
use forge_repositories::InMemoryVLockerHotRepository;
|
||||||
use forge_services::{VLockerHotStateService, VLockerService};
|
use forge_services::{VLockerHotStateService, VLockerService};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use crate::adapters::ExtensionRedisClient;
|
|
||||||
use crate::enqueue_persistence_task;
|
use crate::enqueue_persistence_task;
|
||||||
use crate::helpers::resolve_uid;
|
use crate::helpers::resolve_uid;
|
||||||
use crate::log::log;
|
use crate::log::log;
|
||||||
|
use crate::storage::VLockerStorageRepository;
|
||||||
|
|
||||||
static VLOCKER_SERVICE: LazyLock<VLockerService<RedisVLockerRepository<ExtensionRedisClient>>> =
|
static VLOCKER_SERVICE: LazyLock<VLockerService<VLockerStorageRepository>> =
|
||||||
LazyLock::new(|| {
|
LazyLock::new(|| VLockerService::new(VLockerStorageRepository::configured()));
|
||||||
let redis_client = ExtensionRedisClient::new();
|
|
||||||
let repository = RedisVLockerRepository::new(redis_client);
|
|
||||||
VLockerService::new(repository)
|
|
||||||
});
|
|
||||||
static HOT_VLOCKER_SERVICE: LazyLock<
|
static HOT_VLOCKER_SERVICE: LazyLock<
|
||||||
VLockerHotStateService<
|
VLockerHotStateService<VLockerStorageRepository, InMemoryVLockerHotRepository>,
|
||||||
RedisVLockerRepository<ExtensionRedisClient>,
|
|
||||||
InMemoryVLockerHotRepository,
|
|
||||||
>,
|
|
||||||
> = LazyLock::new(|| {
|
> = LazyLock::new(|| {
|
||||||
let redis_client = ExtensionRedisClient::new();
|
let repository = VLockerStorageRepository::configured();
|
||||||
let repository = RedisVLockerRepository::new(redis_client);
|
|
||||||
let hot_repository = InMemoryVLockerHotRepository::new();
|
let hot_repository = InMemoryVLockerHotRepository::new();
|
||||||
VLockerHotStateService::new(repository, hot_repository)
|
VLockerHotStateService::new(repository, hot_repository)
|
||||||
});
|
});
|
||||||
|
|
||||||
pub(crate) fn hot_service() -> &'static VLockerHotStateService<
|
pub(crate) fn hot_service()
|
||||||
RedisVLockerRepository<ExtensionRedisClient>,
|
-> &'static VLockerHotStateService<VLockerStorageRepository, InMemoryVLockerHotRepository> {
|
||||||
InMemoryVLockerHotRepository,
|
|
||||||
> {
|
|
||||||
&HOT_VLOCKER_SERVICE
|
&HOT_VLOCKER_SERVICE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,10 +13,12 @@ pub trait PhoneRepository: Send + Sync {
|
|||||||
fn append_message(&self, uid: &str, message: PhoneMessage) -> Result<(), String>;
|
fn append_message(&self, uid: &str, message: PhoneMessage) -> Result<(), String>;
|
||||||
fn list_messages(&self, uid: &str) -> Result<Vec<PhoneMessage>, String>;
|
fn list_messages(&self, uid: &str) -> Result<Vec<PhoneMessage>, String>;
|
||||||
fn mark_message_read(&self, uid: &str, message_id: &str) -> Result<bool, String>;
|
fn mark_message_read(&self, uid: &str, message_id: &str) -> Result<bool, String>;
|
||||||
|
fn delete_message(&self, uid: &str, message_id: &str) -> Result<bool, String>;
|
||||||
|
|
||||||
fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String>;
|
fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String>;
|
||||||
fn list_emails(&self, uid: &str) -> Result<Vec<PhoneEmail>, String>;
|
fn list_emails(&self, uid: &str) -> Result<Vec<PhoneEmail>, String>;
|
||||||
fn mark_email_read(&self, uid: &str, email_id: &str) -> Result<bool, String>;
|
fn mark_email_read(&self, uid: &str, email_id: &str) -> Result<bool, String>;
|
||||||
|
fn delete_email(&self, uid: &str, email_id: &str) -> Result<bool, String>;
|
||||||
|
|
||||||
fn next_sequence(&self) -> Result<u64, String>;
|
fn next_sequence(&self) -> Result<u64, String>;
|
||||||
}
|
}
|
||||||
@ -146,6 +148,19 @@ impl PhoneRepository for InMemoryPhoneRepository {
|
|||||||
Ok(found)
|
Ok(found)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn delete_message(&self, uid: &str, message_id: &str) -> Result<bool, String> {
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.write()
|
||||||
|
.map_err(|_| "Phone message state lock poisoned.".to_string())?;
|
||||||
|
let Some(messages) = state.messages.get_mut(uid) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let original_len = messages.len();
|
||||||
|
messages.retain(|message| message.id != message_id);
|
||||||
|
Ok(messages.len() != original_len)
|
||||||
|
}
|
||||||
|
|
||||||
fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String> {
|
fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String> {
|
||||||
self.state
|
self.state
|
||||||
.write()
|
.write()
|
||||||
@ -193,6 +208,19 @@ impl PhoneRepository for InMemoryPhoneRepository {
|
|||||||
Ok(found)
|
Ok(found)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn delete_email(&self, uid: &str, email_id: &str) -> Result<bool, String> {
|
||||||
|
let mut state = self
|
||||||
|
.state
|
||||||
|
.write()
|
||||||
|
.map_err(|_| "Phone email state lock poisoned.".to_string())?;
|
||||||
|
let Some(emails) = state.emails.get_mut(uid) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let original_len = emails.len();
|
||||||
|
emails.retain(|email| email.id != email_id);
|
||||||
|
Ok(emails.len() != original_len)
|
||||||
|
}
|
||||||
|
|
||||||
fn next_sequence(&self) -> Result<u64, String> {
|
fn next_sequence(&self) -> Result<u64, String> {
|
||||||
let mut state = self
|
let mut state = self
|
||||||
.state
|
.state
|
||||||
@ -476,6 +504,38 @@ impl<C: RedisClient> PhoneRepository for RedisPhoneRepository<C> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn delete_message(&self, uid: &str, message_id: &str) -> Result<bool, String> {
|
||||||
|
let exists = self
|
||||||
|
.client
|
||||||
|
.list_range(Self::user_messages_key(uid), 0, -1)?
|
||||||
|
.iter()
|
||||||
|
.any(|id| id == message_id);
|
||||||
|
if !exists {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = self.load_message_record(uid, message_id)?;
|
||||||
|
self.client
|
||||||
|
.list_del(Self::user_messages_key(uid), 0, message_id.to_string())?;
|
||||||
|
self.client
|
||||||
|
.hash_del(Self::message_read_key(uid), message_id.to_string())?;
|
||||||
|
|
||||||
|
if let Some(message) = message {
|
||||||
|
let other_uid = if message.from == uid {
|
||||||
|
&message.to
|
||||||
|
} else {
|
||||||
|
&message.from
|
||||||
|
};
|
||||||
|
self.client.list_del(
|
||||||
|
Self::message_thread_key(uid, other_uid),
|
||||||
|
0,
|
||||||
|
message_id.to_string(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String> {
|
fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String> {
|
||||||
self.save_email_record(&email)?;
|
self.save_email_record(&email)?;
|
||||||
self.client
|
self.client
|
||||||
@ -518,6 +578,23 @@ impl<C: RedisClient> PhoneRepository for RedisPhoneRepository<C> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn delete_email(&self, uid: &str, email_id: &str) -> Result<bool, String> {
|
||||||
|
let exists = self
|
||||||
|
.client
|
||||||
|
.list_range(Self::user_emails_key(uid), 0, -1)?
|
||||||
|
.iter()
|
||||||
|
.any(|id| id == email_id);
|
||||||
|
if !exists {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.client
|
||||||
|
.list_del(Self::user_emails_key(uid), 0, email_id.to_string())?;
|
||||||
|
self.client
|
||||||
|
.hash_del(Self::email_read_key(uid), email_id.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
fn next_sequence(&self) -> Result<u64, String> {
|
fn next_sequence(&self) -> Result<u64, String> {
|
||||||
let value = self.client.incr_key(Self::sequence_key(), 1)?;
|
let value = self.client.incr_key(Self::sequence_key(), 1)?;
|
||||||
u64::try_from(value).map_err(|_| "Phone sequence overflowed.".to_string())
|
u64::try_from(value).map_err(|_| "Phone sequence overflowed.".to_string())
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
use forge_models::{PhoneEmail, PhoneMessage, PhonePayload};
|
use forge_models::{PhoneEmail, PhoneMessage, PhonePayload};
|
||||||
use forge_repositories::PhoneRepository;
|
use forge_repositories::PhoneRepository;
|
||||||
|
|
||||||
|
const FIELD_COMMANDER_UID: &str = "field_commander";
|
||||||
|
|
||||||
pub struct PhoneStateService<R: PhoneRepository> {
|
pub struct PhoneStateService<R: PhoneRepository> {
|
||||||
repository: R,
|
repository: R,
|
||||||
}
|
}
|
||||||
@ -13,15 +15,14 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
|||||||
pub fn init(&self, uid: String) -> Result<PhonePayload, String> {
|
pub fn init(&self, uid: String) -> Result<PhonePayload, String> {
|
||||||
let uid = Self::validate_uid(uid)?;
|
let uid = Self::validate_uid(uid)?;
|
||||||
self.repository.init(&uid)?;
|
self.repository.init(&uid)?;
|
||||||
|
self.repository.add_contact(&uid, &uid)?;
|
||||||
|
self.repository.add_contact(&uid, FIELD_COMMANDER_UID)?;
|
||||||
self.payload_for(&uid)
|
self.payload_for(&uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_contact(&self, uid: String, contact_uid: String) -> Result<bool, String> {
|
pub fn add_contact(&self, uid: String, contact_uid: String) -> Result<bool, String> {
|
||||||
let uid = Self::validate_uid(uid)?;
|
let uid = Self::validate_uid(uid)?;
|
||||||
let contact_uid = Self::validate_uid(contact_uid)?;
|
let contact_uid = Self::validate_uid(contact_uid)?;
|
||||||
if uid == contact_uid {
|
|
||||||
return Err("Cannot add self as a phone contact.".to_string());
|
|
||||||
}
|
|
||||||
self.repository.add_contact(&uid, &contact_uid)
|
self.repository.add_contact(&uid, &contact_uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,6 +47,11 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
|||||||
let from_uid = Self::validate_uid(from_uid)?;
|
let from_uid = Self::validate_uid(from_uid)?;
|
||||||
let to_uid = Self::validate_uid(to_uid)?;
|
let to_uid = Self::validate_uid(to_uid)?;
|
||||||
let message = Self::validate_non_empty(message, "Message body is required.")?;
|
let message = Self::validate_non_empty(message, "Message body is required.")?;
|
||||||
|
Self::validate_send_target(
|
||||||
|
&from_uid,
|
||||||
|
&to_uid,
|
||||||
|
"Field Commander cannot receive player messages.",
|
||||||
|
)?;
|
||||||
let timestamp = Self::parse_timestamp(timestamp);
|
let timestamp = Self::parse_timestamp(timestamp);
|
||||||
let id = format!(
|
let id = format!(
|
||||||
"phone-message:{}:{}:{}",
|
"phone-message:{}:{}:{}",
|
||||||
@ -63,7 +69,9 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.repository.append_message(&from_uid, record.clone())?;
|
self.repository.append_message(&from_uid, record.clone())?;
|
||||||
self.repository.append_message(&to_uid, record.clone())?;
|
if to_uid != from_uid {
|
||||||
|
self.repository.append_message(&to_uid, record.clone())?;
|
||||||
|
}
|
||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,6 +104,12 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
|||||||
self.repository.mark_message_read(&uid, &message_id)
|
self.repository.mark_message_read(&uid, &message_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn delete_message(&self, uid: String, message_id: String) -> Result<bool, String> {
|
||||||
|
let uid = Self::validate_uid(uid)?;
|
||||||
|
let message_id = Self::validate_non_empty(message_id, "Message ID is required.")?;
|
||||||
|
self.repository.delete_message(&uid, &message_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn send_email(
|
pub fn send_email(
|
||||||
&self,
|
&self,
|
||||||
from_uid: String,
|
from_uid: String,
|
||||||
@ -106,8 +120,13 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
|||||||
) -> Result<PhoneEmail, String> {
|
) -> Result<PhoneEmail, String> {
|
||||||
let from_uid = Self::validate_uid(from_uid)?;
|
let from_uid = Self::validate_uid(from_uid)?;
|
||||||
let to_uid = Self::validate_uid(to_uid)?;
|
let to_uid = Self::validate_uid(to_uid)?;
|
||||||
let subject = Self::validate_non_empty(subject, "Email subject is required.")?;
|
let subject = Self::default_subject(subject);
|
||||||
let body = Self::validate_non_empty(body, "Email body is required.")?;
|
let body = Self::validate_non_empty(body, "Email body is required.")?;
|
||||||
|
Self::validate_send_target(
|
||||||
|
&from_uid,
|
||||||
|
&to_uid,
|
||||||
|
"Field Commander cannot receive player emails.",
|
||||||
|
)?;
|
||||||
let timestamp = Self::parse_timestamp(timestamp);
|
let timestamp = Self::parse_timestamp(timestamp);
|
||||||
let id = format!(
|
let id = format!(
|
||||||
"phone-email:{}:{}:{}",
|
"phone-email:{}:{}:{}",
|
||||||
@ -117,7 +136,7 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
|||||||
);
|
);
|
||||||
let record = PhoneEmail {
|
let record = PhoneEmail {
|
||||||
id,
|
id,
|
||||||
from: from_uid,
|
from: from_uid.clone(),
|
||||||
to: to_uid.clone(),
|
to: to_uid.clone(),
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
@ -126,6 +145,10 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.repository.append_email(&to_uid, record.clone())?;
|
self.repository.append_email(&to_uid, record.clone())?;
|
||||||
|
if from_uid != to_uid {
|
||||||
|
self.repository.append_email(&from_uid, record.clone())?;
|
||||||
|
self.repository.mark_email_read(&from_uid, &record.id)?;
|
||||||
|
}
|
||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,6 +163,12 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
|||||||
self.repository.mark_email_read(&uid, &email_id)
|
self.repository.mark_email_read(&uid, &email_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn delete_email(&self, uid: String, email_id: String) -> Result<bool, String> {
|
||||||
|
let uid = Self::validate_uid(uid)?;
|
||||||
|
let email_id = Self::validate_non_empty(email_id, "Email ID is required.")?;
|
||||||
|
self.repository.delete_email(&uid, &email_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn remove(&self, uid: String) -> Result<(), String> {
|
pub fn remove(&self, uid: String) -> Result<(), String> {
|
||||||
let uid = Self::validate_uid(uid)?;
|
let uid = Self::validate_uid(uid)?;
|
||||||
self.repository.remove_phone(&uid)
|
self.repository.remove_phone(&uid)
|
||||||
@ -171,6 +200,23 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_subject(value: String) -> String {
|
||||||
|
let value = value.trim().to_string();
|
||||||
|
if value.is_empty() {
|
||||||
|
"No subject".to_string()
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_send_target(from_uid: &str, to_uid: &str, message: &str) -> Result<(), String> {
|
||||||
|
if to_uid == FIELD_COMMANDER_UID && from_uid != FIELD_COMMANDER_UID {
|
||||||
|
Err(message.to_string())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_timestamp(timestamp: String) -> f64 {
|
fn parse_timestamp(timestamp: String) -> f64 {
|
||||||
timestamp.trim().parse::<f64>().unwrap_or_default()
|
timestamp.trim().parse::<f64>().unwrap_or_default()
|
||||||
}
|
}
|
||||||
@ -212,13 +258,180 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn contact_cannot_reference_self() {
|
fn contact_can_reference_self_for_owner_card() {
|
||||||
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
|
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
service
|
service
|
||||||
.add_contact("same".to_string(), "same".to_string())
|
.add_contact("same".to_string(), "same".to_string())
|
||||||
|
.expect("self contact should be allowed")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_seeds_owner_and_field_commander_contacts() {
|
||||||
|
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
|
||||||
|
|
||||||
|
let payload = service
|
||||||
|
.init("player".to_string())
|
||||||
|
.expect("phone should initialize");
|
||||||
|
|
||||||
|
assert!(payload.contacts.iter().any(|uid| uid == "player"));
|
||||||
|
assert!(payload.contacts.iter().any(|uid| uid == "field_commander"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_cannot_message_field_commander() {
|
||||||
|
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.send_message(
|
||||||
|
"player".to_string(),
|
||||||
|
"field_commander".to_string(),
|
||||||
|
"Test".to_string(),
|
||||||
|
"123".to_string(),
|
||||||
|
)
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_commander_can_message_player() {
|
||||||
|
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.send_message(
|
||||||
|
"field_commander".to_string(),
|
||||||
|
"player".to_string(),
|
||||||
|
"Orders".to_string(),
|
||||||
|
"123".to_string(),
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_cannot_email_field_commander() {
|
||||||
|
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.send_email(
|
||||||
|
"player".to_string(),
|
||||||
|
"field_commander".to_string(),
|
||||||
|
"Subject".to_string(),
|
||||||
|
"Body".to_string(),
|
||||||
|
"123".to_string(),
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn email_allows_empty_subject() {
|
||||||
|
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
|
||||||
|
|
||||||
|
let email = service
|
||||||
|
.send_email(
|
||||||
|
"player".to_string(),
|
||||||
|
"player".to_string(),
|
||||||
|
"".to_string(),
|
||||||
|
"Body".to_string(),
|
||||||
|
"123".to_string(),
|
||||||
|
)
|
||||||
|
.expect("email should allow empty subject");
|
||||||
|
|
||||||
|
assert_eq!(email.subject, "No subject");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_message_is_indexed_once() {
|
||||||
|
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
|
||||||
|
|
||||||
|
service
|
||||||
|
.send_message(
|
||||||
|
"same".to_string(),
|
||||||
|
"same".to_string(),
|
||||||
|
"Test".to_string(),
|
||||||
|
"123".to_string(),
|
||||||
|
)
|
||||||
|
.expect("self message should send");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
service
|
||||||
|
.list_messages("same".to_string())
|
||||||
|
.expect("self messages should load")
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_message_removes_only_requesting_users_index() {
|
||||||
|
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
|
||||||
|
|
||||||
|
let message = service
|
||||||
|
.send_message(
|
||||||
|
"sender".to_string(),
|
||||||
|
"receiver".to_string(),
|
||||||
|
"Test".to_string(),
|
||||||
|
"123".to_string(),
|
||||||
|
)
|
||||||
|
.expect("message should send");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.delete_message("sender".to_string(), message.id.clone())
|
||||||
|
.expect("message should delete")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.list_messages("sender".to_string())
|
||||||
|
.expect("sender messages should load")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
service
|
||||||
|
.list_messages("receiver".to_string())
|
||||||
|
.expect("receiver messages should load")
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_email_removes_requesting_users_index() {
|
||||||
|
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
|
||||||
|
|
||||||
|
let email = service
|
||||||
|
.send_email(
|
||||||
|
"sender".to_string(),
|
||||||
|
"receiver".to_string(),
|
||||||
|
"Subject".to_string(),
|
||||||
|
"Body".to_string(),
|
||||||
|
"123".to_string(),
|
||||||
|
)
|
||||||
|
.expect("email should send");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.delete_email("receiver".to_string(), email.id.clone())
|
||||||
|
.expect("email should delete")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.list_emails("receiver".to_string())
|
||||||
|
.expect("receiver emails should load")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
service
|
||||||
|
.list_emails("sender".to_string())
|
||||||
|
.expect("sender emails should remain")
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user