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);
|
||||
}] 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
|
||||
[QGVAR(responseEmailSent), {
|
||||
params [["_emailObj", createHashMap, [createHashMap]]];
|
||||
@ -215,6 +226,17 @@ if (isNil QGVAR(PhoneClass)) then { [] call FUNC(initClass); };
|
||||
[QGVAR(updateEmailRead), [_emailId]] call CFUNC(localEvent);
|
||||
}] 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
|
||||
[QGVAR(responseRemovePhone), {
|
||||
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)]]; };
|
||||
}] 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), {
|
||||
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)]]; };
|
||||
}] 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";
|
||||
};
|
||||
};
|
||||
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": {
|
||||
private _toUid = _data get "toUid";
|
||||
private _subject = _data get "subject";
|
||||
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);
|
||||
} else {
|
||||
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";
|
||||
};
|
||||
};
|
||||
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": {
|
||||
private _notes = GVAR(PhoneClass) call ["getAllNotes", []];
|
||||
|
||||
|
||||
@ -1170,6 +1170,7 @@ body::-webkit-scrollbar {
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.message-header {
|
||||
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 {
|
||||
height: 100%;
|
||||
@ -1460,6 +1510,7 @@ body::-webkit-scrollbar {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ---- ../styles/components/mail.css ---- */
|
||||
/* Mail App */
|
||||
.mail-content,
|
||||
@ -1558,8 +1609,7 @@ body::-webkit-scrollbar {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.mail-send-button,
|
||||
.nav-action-button {
|
||||
.mail-send-button {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--accent-color);
|
||||
@ -1572,12 +1622,6 @@ body::-webkit-scrollbar {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.nav-action-button {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.mail-detail {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
@ -1604,6 +1648,23 @@ body::-webkit-scrollbar {
|
||||
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 ---- */
|
||||
/* 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() {
|
||||
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(
|
||||
'div',
|
||||
@ -180,9 +222,24 @@ class App extends Component {
|
||||
// Call modal
|
||||
showModal && selectedContact && new Modal({
|
||||
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 }),
|
||||
onConfirm: () => {
|
||||
if (selectedContact.canCall === false) {
|
||||
globalState.setState({ showModal: false, selectedContact: null });
|
||||
return;
|
||||
}
|
||||
|
||||
globalState.setState({
|
||||
phoneNumber: selectedContact.phone,
|
||||
showModal: false,
|
||||
@ -190,7 +247,15 @@ class App extends Component {
|
||||
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
|
||||
|
||||
@ -42,14 +42,17 @@ class ContactItem extends Component {
|
||||
*/
|
||||
render() {
|
||||
const { contact } = this.props;
|
||||
const displayName = contact.fullName || contact.name;
|
||||
const subtitleParts = [contact.phone];
|
||||
if (contact.system) subtitleParts.push('system contact');
|
||||
|
||||
return this.createElement(
|
||||
'li',
|
||||
{
|
||||
className: 'contact-item',
|
||||
className: `contact-item${contact.system ? ' system-contact' : ''}`,
|
||||
onClick: this.handleClick,
|
||||
role: 'button',
|
||||
'aria-label': `Contact ${contact.name}`,
|
||||
'aria-label': `Contact ${displayName}`,
|
||||
},
|
||||
// Avatar section
|
||||
this.createElement(
|
||||
@ -61,7 +64,12 @@ class ContactItem extends Component {
|
||||
contact.avatar
|
||||
),
|
||||
// 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 {
|
||||
constructor(props = {}) {
|
||||
super(props);
|
||||
const contacts = this.emailableContacts(props.contacts || []);
|
||||
const defaultRecipient = contacts.length === 1 ? (contacts[0].uid || contacts[0].id || '') : '';
|
||||
this.state = {
|
||||
toUid: '',
|
||||
toUid: defaultRecipient,
|
||||
subject: '',
|
||||
body: ''
|
||||
};
|
||||
|
||||
this.toRef = null;
|
||||
this.subjectRef = null;
|
||||
this.bodyRef = null;
|
||||
this.lastSendAt = 0;
|
||||
|
||||
this.handleSend = this.handleSend.bind(this);
|
||||
this.syncSubject = this.syncSubject.bind(this);
|
||||
this.syncBody = this.syncBody.bind(this);
|
||||
}
|
||||
|
||||
handleSend() {
|
||||
const toUid = (this.state.toUid || '').trim();
|
||||
const subject = (this.state.subject || '').trim();
|
||||
const body = (this.state.body || '').trim();
|
||||
emailableContacts(contacts = []) {
|
||||
return contacts.filter((contact) => contact && contact.canEmail !== false && (contact.uid || contact.id));
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log('MailComposer: sending email', { toUid, subjectLength: subject.length, bodyLength: body.length });
|
||||
A3API.SendAlert(JSON.stringify({
|
||||
event: 'phone::send::email',
|
||||
data: { toUid, subject, body }
|
||||
}));
|
||||
} else {
|
||||
console.warn('MailComposer: A3API.SendAlert unavailable');
|
||||
}
|
||||
|
||||
globalState.setState({
|
||||
@ -33,14 +92,14 @@ class MailComposer extends Component {
|
||||
}
|
||||
|
||||
renderContactOptions() {
|
||||
const contacts = this.props.contacts || [];
|
||||
const contacts = this.emailableContacts(this.props.contacts || []);
|
||||
|
||||
return [
|
||||
this.createElement('option', { value: '' }, 'Select recipient'),
|
||||
...contacts.map((contact) => this.createElement(
|
||||
'option',
|
||||
{ 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(
|
||||
'select',
|
||||
{
|
||||
id: 'phone-mail-recipient',
|
||||
name: 'phone-mail-recipient',
|
||||
value: this.state.toUid,
|
||||
onInput: (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'
|
||||
},
|
||||
...this.renderContactOptions()
|
||||
@ -64,17 +132,27 @@ class MailComposer extends Component {
|
||||
this.createElement('label', {},
|
||||
'Subject',
|
||||
this.createElement('input', {
|
||||
id: 'phone-mail-subject',
|
||||
name: 'phone-mail-subject',
|
||||
type: 'text',
|
||||
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'
|
||||
})
|
||||
),
|
||||
this.createElement('label', {},
|
||||
'Message',
|
||||
this.createElement('textarea', {
|
||||
id: 'phone-mail-body',
|
||||
name: 'phone-mail-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...',
|
||||
rows: 8
|
||||
})
|
||||
@ -84,7 +162,8 @@ class MailComposer extends Component {
|
||||
{
|
||||
type: 'button',
|
||||
className: 'mail-send-button',
|
||||
onClick: this.handleSend
|
||||
onClick: this.handleSend,
|
||||
onMouseDown: this.handleSend
|
||||
},
|
||||
'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() {
|
||||
const { email } = this.props;
|
||||
|
||||
@ -46,7 +57,16 @@ class MailDetail extends Component {
|
||||
this.createElement('span', {}, `To: ${this.resolveContactName(email.to) || 'Unknown'}`),
|
||||
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',
|
||||
props: {
|
||||
type: 'button',
|
||||
className: 'nav-action-button',
|
||||
className: 'nav-button add-button',
|
||||
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: '+'
|
||||
} : null
|
||||
|
||||
@ -146,6 +146,10 @@ class ConversationView extends Component {
|
||||
const { newMessage } = this.state;
|
||||
const { conversation } = this.props;
|
||||
|
||||
if (conversation && conversation.canMessage === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newMessage.trim()) {
|
||||
// Create new message object
|
||||
const newMessageObj = {
|
||||
@ -229,6 +233,9 @@ class ConversationView extends Component {
|
||||
* @private
|
||||
*/
|
||||
renderMessageForm() {
|
||||
const { conversation } = this.props;
|
||||
const canMessage = !conversation || conversation.canMessage !== false;
|
||||
|
||||
return this.createElement(
|
||||
'div',
|
||||
{
|
||||
@ -238,9 +245,11 @@ class ConversationView extends Component {
|
||||
},
|
||||
this.createElement('textarea', {
|
||||
className: 'message-input',
|
||||
placeholder: 'Type a message...',
|
||||
placeholder: canMessage ? 'Type a message...' : 'Replies disabled for this contact',
|
||||
value: this.state.newMessage,
|
||||
disabled: !canMessage,
|
||||
onInput: (e) => {
|
||||
if (!canMessage) return;
|
||||
this.handleInputChange(e);
|
||||
// Auto-grow logic
|
||||
if (e.target) {
|
||||
@ -250,7 +259,7 @@ class ConversationView extends Component {
|
||||
},
|
||||
onKeyDown: (e) => {
|
||||
// Send message on Enter key (but not Shift+Enter)
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (canMessage && e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.handleSendMessage();
|
||||
}
|
||||
@ -272,7 +281,8 @@ class ConversationView extends Component {
|
||||
type: 'button',
|
||||
className: 'send-button',
|
||||
onClick: this.handleSendMessage,
|
||||
'aria-label': 'Send message'
|
||||
disabled: !canMessage,
|
||||
'aria-label': canMessage ? 'Send message' : 'Replies disabled'
|
||||
},
|
||||
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>',
|
||||
|
||||
@ -20,6 +20,7 @@ class MessageItem extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
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
|
||||
* @param {Date} timestamp - The timestamp to format
|
||||
@ -40,8 +54,12 @@ class MessageItem extends Component {
|
||||
* @private
|
||||
*/
|
||||
formatTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const now = new Date();
|
||||
const messageTime = new Date(timestamp);
|
||||
if (Number.isNaN(messageTime.getTime())) return '';
|
||||
|
||||
const diffInHours = (now - messageTime) / (1000 * 60 * 60);
|
||||
|
||||
if (diffInHours < 1) {
|
||||
@ -91,7 +109,7 @@ class MessageItem extends Component {
|
||||
'span',
|
||||
{
|
||||
className: 'message-time',
|
||||
'aria-label': `Sent ${this.formatTime(message.timestamp)}`,
|
||||
'aria-label': message.timestamp ? `Sent ${this.formatTime(message.timestamp)}` : '',
|
||||
},
|
||||
this.formatTime(message.timestamp)
|
||||
)
|
||||
@ -105,6 +123,8 @@ class MessageItem extends Component {
|
||||
* @private
|
||||
*/
|
||||
renderMessagePreview(message) {
|
||||
const preview = message.hasConversation ? message.lastMessage : 'Start conversation';
|
||||
|
||||
return this.createElement(
|
||||
'div',
|
||||
{ className: 'message-preview' },
|
||||
@ -114,7 +134,7 @@ class MessageItem extends Component {
|
||||
role: 'text',
|
||||
'aria-label': 'Last message',
|
||||
},
|
||||
message.lastMessage
|
||||
preview
|
||||
),
|
||||
message.unread > 0 &&
|
||||
this.createElement(
|
||||
@ -136,6 +156,7 @@ class MessageItem extends Component {
|
||||
render() {
|
||||
const { message } = this.props;
|
||||
const initials = this.getContactInitials(message.contactName);
|
||||
const canDelete = Array.isArray(message.conversation) && message.conversation.length > 0;
|
||||
|
||||
return this.createElement(
|
||||
'div',
|
||||
@ -159,7 +180,22 @@ class MessageItem extends Component {
|
||||
},
|
||||
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) {
|
||||
super(props);
|
||||
this.state = {
|
||||
filteredMessages: props.messages || [],
|
||||
filteredMessages: this.buildRows(props.messages || [], props.contacts || [], ''),
|
||||
searchTerm: ''
|
||||
};
|
||||
}
|
||||
@ -26,24 +26,94 @@ class MessagesList extends Component {
|
||||
* @param {Object} nextProps - Next props
|
||||
*/
|
||||
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
|
||||
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
|
||||
* @param {string} searchTerm - The search term to filter messages
|
||||
* @private
|
||||
*/
|
||||
handleSearch(searchTerm) {
|
||||
const { messages = [] } = this.props;
|
||||
const searchTermLower = searchTerm.toLowerCase();
|
||||
|
||||
const filtered = messages.filter(message =>
|
||||
message.contactName.toLowerCase().includes(searchTermLower)
|
||||
);
|
||||
const { messages = [], contacts = [] } = this.props;
|
||||
const filtered = this.buildRows(messages, contacts, searchTerm);
|
||||
|
||||
this.setState({
|
||||
filteredMessages: filtered,
|
||||
@ -57,14 +127,26 @@ class MessagesList extends Component {
|
||||
* @returns {Array<MessageItem>} Array of MessageItem components
|
||||
*/
|
||||
renderMessageItems() {
|
||||
const { onMessageClick } = this.props;
|
||||
const { onMessageClick, onMessageDelete } = this.props;
|
||||
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(
|
||||
(message) =>
|
||||
new MessageItem({
|
||||
message,
|
||||
onClick: onMessageClick,
|
||||
onDelete: onMessageDelete,
|
||||
key: message.id,
|
||||
})
|
||||
);
|
||||
@ -88,7 +170,7 @@ class MessagesList extends Component {
|
||||
}
|
||||
},
|
||||
new SearchBar({
|
||||
placeholder: 'Search by contact name...',
|
||||
placeholder: this.props.searchPlaceholder || 'Search by contact name...',
|
||||
onSearch: this.handleSearch.bind(this),
|
||||
value: searchTerm
|
||||
}),
|
||||
@ -108,4 +190,4 @@ class MessagesList extends Component {
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,61 +1,119 @@
|
||||
/**
|
||||
* @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.
|
||||
* @fileoverview Main entry point for the Messages application.
|
||||
*/
|
||||
|
||||
// Initialize the messages app
|
||||
function initializeMessagesApp(container) {
|
||||
// Get current messages and selected conversation from global state
|
||||
const { messages, selectedConversation } = globalState.getState();
|
||||
const { messages = [], contacts = [], selectedConversation, showMessageContactPicker } = globalState.getState();
|
||||
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.setAttribute('role', 'main');
|
||||
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({
|
||||
title: selectedConversation ? selectedConversation.contactName : 'Messages',
|
||||
showBackButton: !!selectedConversation
|
||||
title: selectedConversation ? selectedConversation.contactName : (showMessageContactPicker ? 'New Conversation' : 'Messages'),
|
||||
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);
|
||||
|
||||
// Content container for either the list or conversation
|
||||
const contentContainer = document.createElement('div');
|
||||
contentContainer.className = 'content';
|
||||
appContainer.appendChild(contentContainer);
|
||||
|
||||
/**
|
||||
* Render either the conversation view or the messages list
|
||||
* - If a conversation is selected, show ConversationView
|
||||
* - Otherwise, show MessagesList
|
||||
*/
|
||||
if (selectedConversation) {
|
||||
const conversationView = new ConversationView({ conversation: selectedConversation });
|
||||
conversationView.mount(contentContainer);
|
||||
} else {
|
||||
const messagesList = new MessagesList({
|
||||
messages,
|
||||
onMessageClick: (message) => {
|
||||
globalState.setState({ selectedConversation: message });
|
||||
}
|
||||
contacts,
|
||||
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);
|
||||
}
|
||||
|
||||
// Mount the app container
|
||||
container.appendChild(appContainer);
|
||||
}
|
||||
|
||||
// Make initialization function globally available
|
||||
window.initializeMessagesApp = initializeMessagesApp;
|
||||
window.initializeMessagesApp = initializeMessagesApp;
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
*/
|
||||
|
||||
class Dialpad extends Component {
|
||||
static fieldCommanderPhoneNumber = '0160000000';
|
||||
|
||||
static assetPath(...parts) {
|
||||
return PhoneMedia.base64Path('images', ...parts);
|
||||
}
|
||||
@ -174,7 +176,11 @@ class Dialpad extends Component {
|
||||
* @description Initiates a phone call and starts the call timer
|
||||
*/
|
||||
handleCall() {
|
||||
if (this.state.phoneNumber && !this.state.isCallActive) {
|
||||
if (
|
||||
this.state.phoneNumber &&
|
||||
!this.state.isCallActive &&
|
||||
this.cleanPhoneNumber(this.state.phoneNumber) !== Dialpad.fieldCommanderPhoneNumber
|
||||
) {
|
||||
this.setState({
|
||||
isCallActive: true,
|
||||
callDuration: 0,
|
||||
@ -304,7 +310,7 @@ class Dialpad extends Component {
|
||||
'aria-label': 'Make call',
|
||||
};
|
||||
|
||||
if (isPhoneNumberEmpty) {
|
||||
if (isPhoneNumberEmpty || this.cleanPhoneNumber(phoneNumber) === Dialpad.fieldCommanderPhoneNumber) {
|
||||
callButtonProps.disabled = true;
|
||||
}
|
||||
|
||||
|
||||
@ -31,7 +31,9 @@ class HomeIndicator extends Component {
|
||||
globalState.setState({
|
||||
currentApp: 'home',
|
||||
selectedConversation: null,
|
||||
selectedConversationRaw: null,
|
||||
selectedContact: null,
|
||||
showMessageContactPicker: false,
|
||||
showModal: false,
|
||||
});
|
||||
}
|
||||
|
||||
@ -66,11 +66,15 @@ class Modal extends Component {
|
||||
* @returns {HTMLElement} The rendered actions element
|
||||
* @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(
|
||||
'div',
|
||||
{ className: 'modal-actions' },
|
||||
this.createElement(
|
||||
hideCancel ? null : this.createElement(
|
||||
'button',
|
||||
{
|
||||
className: 'button secondary',
|
||||
@ -80,7 +84,18 @@ class Modal extends Component {
|
||||
},
|
||||
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',
|
||||
{
|
||||
className: 'button',
|
||||
@ -98,7 +113,7 @@ class Modal extends Component {
|
||||
* @returns {HTMLElement} The rendered modal element
|
||||
*/
|
||||
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) {
|
||||
return this.createElement('div', {
|
||||
@ -147,7 +162,7 @@ class Modal extends Component {
|
||||
},
|
||||
...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) {
|
||||
globalState.setState({
|
||||
selectedConversation: null,
|
||||
selectedConversationRaw: null,
|
||||
});
|
||||
return; // Exit early, don't execute the rest
|
||||
}
|
||||
|
||||
if (currentState.showMessageContactPicker) {
|
||||
globalState.setState({
|
||||
showMessageContactPicker: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentState.selectedEmail || currentState.showEmailComposer) {
|
||||
globalState.setState({
|
||||
selectedEmail: null,
|
||||
@ -58,7 +66,9 @@ class NavigationBar extends Component {
|
||||
currentApp: 'home',
|
||||
previousApp: null,
|
||||
selectedConversation: null,
|
||||
selectedConversationRaw: null,
|
||||
selectedContact: null,
|
||||
showMessageContactPicker: false,
|
||||
showModal: false,
|
||||
});
|
||||
}
|
||||
|
||||
@ -195,8 +195,14 @@ class Component {
|
||||
Object.assign(element.style, value);
|
||||
} else if (key === 'ref' && typeof value === 'function') {
|
||||
value(element);
|
||||
} else {
|
||||
} else if (typeof value === 'boolean') {
|
||||
if (value) {
|
||||
element.setAttribute(key, key);
|
||||
}
|
||||
} else if (value !== null && value !== undefined) {
|
||||
element.setAttribute(key, value);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ const initialAppState = {
|
||||
// UI state
|
||||
selectedContact: null,
|
||||
selectedConversation: null,
|
||||
showMessageContactPicker: false,
|
||||
newMessage: '',
|
||||
currentUid: null,
|
||||
|
||||
|
||||
@ -110,10 +110,15 @@ function normalizeContacts(contacts) {
|
||||
id: uid || contact.phone || name,
|
||||
uid,
|
||||
name,
|
||||
fullName: contact.fullName || name,
|
||||
phone: contact.phone || '',
|
||||
email: contact.email || '',
|
||||
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
|
||||
function rebuildMessageSummariesFromRaw() {
|
||||
try {
|
||||
@ -316,6 +345,7 @@ function rebuildMessageSummariesFromRaw() {
|
||||
id: otherUid,
|
||||
contactId: otherUid,
|
||||
contactName: contact.name || otherUid,
|
||||
canMessage: contact.canMessage !== false,
|
||||
lastMessage: (last && (last.message || last.text)) || '',
|
||||
timestamp: toJsDate(last && last.timestamp),
|
||||
unread: arr.filter(m => m.read === false && m.to === currentUid).length || 0,
|
||||
@ -336,6 +366,7 @@ function rebuildMessageSummariesFromRaw() {
|
||||
id: selectedConversationRaw.otherUid,
|
||||
contactId: selectedConversationRaw.otherUid,
|
||||
contactName: contact.name,
|
||||
canMessage: contact.canMessage !== false,
|
||||
lastMessage: thread.length ? (thread[thread.length - 1].message || thread[thread.length - 1].text) : '',
|
||||
timestamp: thread.length ? toJsDate(thread[thread.length - 1].timestamp) : new Date(),
|
||||
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
|
||||
let lastNotesRequest = 0;
|
||||
const NOTES_REQUEST_COOLDOWN = 1000; // 1 second cooldown
|
||||
@ -853,12 +900,14 @@ window.updateMessageThread = updateMessageThread;
|
||||
window.updateMessageSent = updateMessageSent;
|
||||
window.updateMessageReceived = updateMessageReceived;
|
||||
window.updateMessageRead = updateMessageRead;
|
||||
window.updateMessageDeleted = updateMessageDeleted;
|
||||
// Emails
|
||||
window.requestEmails = requestEmails;
|
||||
window.updateEmails = updateEmails;
|
||||
window.updateEmailSent = updateEmailSent;
|
||||
window.updateEmailReceived = updateEmailReceived;
|
||||
window.updateEmailRead = updateEmailRead;
|
||||
window.updateEmailDeleted = updateEmailDeleted;
|
||||
window.requestNotes = requestNotes;
|
||||
window.loadNotes = loadNotes;
|
||||
window.saveNote = saveNote;
|
||||
|
||||
@ -95,8 +95,7 @@
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.mail-send-button,
|
||||
.nav-action-button {
|
||||
.mail-send-button {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--accent-color);
|
||||
@ -109,12 +108,6 @@
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.nav-action-button {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.mail-detail {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
@ -140,3 +133,20 @@
|
||||
line-height: 1.45;
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.message-header {
|
||||
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 {
|
||||
height: 100%;
|
||||
@ -320,4 +370,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,9 +60,6 @@ GVAR(GarageBaseStore) = compileFinal createHashMapFromArray [
|
||||
if (isNull _player) exitWith { createHashMap };
|
||||
|
||||
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);
|
||||
_garage
|
||||
|
||||
@ -60,9 +60,6 @@ GVAR(LockerBaseStore) = compileFinal createHashMapFromArray [
|
||||
if (isNull _player) exitWith { createHashMap };
|
||||
|
||||
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);
|
||||
_locker
|
||||
|
||||
@ -96,7 +96,7 @@ if (isNil QGVAR(PhoneStore)) then { [] call FUNC(initPhoneStore); };
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
}] 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); };
|
||||
}] 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
|
||||
[QGVAR(requestSendEmail), {
|
||||
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";
|
||||
};
|
||||
|
||||
@ -154,7 +167,7 @@ if (isNil QGVAR(PhoneStore)) then { [] call FUNC(initPhoneStore); };
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
}] 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); };
|
||||
}] 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
|
||||
[QGVAR(requestRemovePhone), {
|
||||
params [["_uid", "", [""]], ["_player", objNull, [objNull]]];
|
||||
|
||||
@ -48,6 +48,9 @@ GVAR(ContactStore) = createHashMapObject [[
|
||||
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]];
|
||||
true
|
||||
}],
|
||||
@ -59,11 +62,6 @@ GVAR(ContactStore) = createHashMapObject [[
|
||||
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]]];
|
||||
if (_added) then { _self call ["refreshContacts", [_uid]]; };
|
||||
_added
|
||||
@ -104,7 +102,6 @@ GVAR(ContactStore) = createHashMapObject [[
|
||||
private _matchedUid = "";
|
||||
{
|
||||
private _candidateUid = _x;
|
||||
if (_candidateUid isEqualTo _requesterUid) then { continue; };
|
||||
|
||||
private _actorValue = EGVAR(actor,ActorStore) call ["getFieldOrDefault", [_candidateUid, _field, ""]];
|
||||
if (_actorValue isEqualType "" && { toLowerANSI _actorValue isEqualTo _normalizedValue }) exitWith {
|
||||
@ -151,8 +148,31 @@ GVAR(ContactStore) = createHashMapObject [[
|
||||
};
|
||||
|
||||
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;
|
||||
if (_contactUid isEqualTo _fieldCommanderUid) then {
|
||||
_contactObjects pushBack _fieldCommanderContact;
|
||||
continue;
|
||||
};
|
||||
|
||||
private _contactData = EGVAR(actor,ActorStore) call ["load", [_contactUid]];
|
||||
|
||||
if (_contactData isNotEqualTo createHashMap) then {
|
||||
@ -165,12 +185,17 @@ GVAR(ContactStore) = createHashMapObject [[
|
||||
_contactObjects pushBack createHashMapFromArray [
|
||||
["uid", _contactUid],
|
||||
["name", _name],
|
||||
["fullName", _name],
|
||||
["phone", _contactData getOrDefault ["phone_number", ""]],
|
||||
["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);
|
||||
if (!isNull _player) then {
|
||||
|
||||
@ -65,8 +65,9 @@ GVAR(EmailStore) = createHashMapObject [[
|
||||
}],
|
||||
["sendEmail", {
|
||||
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";
|
||||
false
|
||||
};
|
||||
@ -84,7 +85,9 @@ GVAR(EmailStore) = createHashMapObject [[
|
||||
_self call ["callPhoneBool", ["phone:emails:mark_read", [_uid, _emailId]]]
|
||||
}],
|
||||
["deleteEmail", {
|
||||
false
|
||||
params [["_uid", "", [""]], ["_emailId", "", [""]]];
|
||||
if (_uid isEqualTo "" || { _emailId isEqualTo "" }) exitWith { false };
|
||||
_self call ["callPhoneBool", ["phone:emails:delete", [_uid, _emailId]]]
|
||||
}],
|
||||
["remove", {
|
||||
params [["_uid", "", [""]]];
|
||||
|
||||
@ -83,6 +83,11 @@ GVAR(MessageStore) = createHashMapObject [[
|
||||
if (_uid isEqualTo "" || { _messageId isEqualTo "" }) exitWith { false };
|
||||
_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", {
|
||||
params [["_uid", "", [""]]];
|
||||
if (_uid isEqualTo "") exitWith { [] };
|
||||
|
||||
@ -114,6 +114,10 @@ GVAR(PhoneStore) = createHashMapObject [[
|
||||
params [["_uid", "", [""]], ["_messageId", "", [""]]];
|
||||
GVAR(MessageStore) call ["markMessageRead", [_uid, _messageId]]
|
||||
}],
|
||||
["deleteMessage", {
|
||||
params [["_uid", "", [""]], ["_messageId", "", [""]]];
|
||||
GVAR(MessageStore) call ["deleteMessage", [_uid, _messageId]]
|
||||
}],
|
||||
["syncMessageIndices", {
|
||||
params [["_uid", "", [""]]];
|
||||
GVAR(MessageStore) call ["syncMessageIndices", [_uid]]
|
||||
@ -130,6 +134,10 @@ GVAR(PhoneStore) = createHashMapObject [[
|
||||
params [["_uid", "", [""]], ["_emailId", "", [""]]];
|
||||
GVAR(EmailStore) call ["markEmailRead", [_uid, _emailId]]
|
||||
}],
|
||||
["deleteEmail", {
|
||||
params [["_uid", "", [""]], ["_emailId", "", [""]]];
|
||||
GVAR(EmailStore) call ["deleteEmail", [_uid, _emailId]]
|
||||
}],
|
||||
["remove", {
|
||||
params [["_uid", "", [""]]];
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ crate-type = ["cdylib"]
|
||||
[dependencies]
|
||||
arma-rs = { workspace = true }
|
||||
base64 = "0.22.1"
|
||||
bb8-redis = "0.25.0-rc.1"
|
||||
bb8-redis = "0.26.0"
|
||||
chrono = { workspace = true }
|
||||
forge-icom = { path = "../../../bin/icom" }
|
||||
forge-models = { path = "../../../lib/models", features = ["actor"] }
|
||||
@ -20,6 +20,7 @@ forge-shared = { path = "../../../lib/shared" }
|
||||
redis = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
surrealdb = { version = "2", default-features = false, features = ["protocol-http", "rustls"] }
|
||||
tokio = { workspace = true }
|
||||
toml = "0.9.8"
|
||||
toml = "1.1.2"
|
||||
uuid = { workspace = true }
|
||||
|
||||
@ -2,6 +2,12 @@
|
||||
# Copy this file to config.toml and modify as needed
|
||||
# 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 server connection settings
|
||||
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
|
||||
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:
|
||||
|
||||
# Development (local Redis)
|
||||
|
||||
@ -4,38 +4,31 @@
|
||||
//! Handles SQF command mapping and parameter validation.
|
||||
|
||||
use arma_rs::{CallContext, Group};
|
||||
use forge_repositories::{InMemoryActorHotRepository, RedisActorRepository};
|
||||
use forge_repositories::InMemoryActorHotRepository;
|
||||
use forge_services::{ActorHotStateService, ActorService};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::adapters::ExtensionRedisClient;
|
||||
use crate::enqueue_persistence_task;
|
||||
use crate::helpers::resolve_uid;
|
||||
use crate::log::log;
|
||||
use crate::storage::ActorStorageRepository;
|
||||
|
||||
/// Global actor service instance.
|
||||
///
|
||||
/// Lazily initialized singleton combining Redis adapter, repository, and service layers.
|
||||
static ACTOR_SERVICE: LazyLock<ActorService<RedisActorRepository<ExtensionRedisClient>>> =
|
||||
LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisActorRepository::new(redis_client);
|
||||
ActorService::new(repository)
|
||||
});
|
||||
static ACTOR_SERVICE: LazyLock<ActorService<ActorStorageRepository>> =
|
||||
LazyLock::new(|| ActorService::new(ActorStorageRepository::configured()));
|
||||
static HOT_ACTOR_SERVICE: LazyLock<
|
||||
ActorHotStateService<RedisActorRepository<ExtensionRedisClient>, InMemoryActorHotRepository>,
|
||||
ActorHotStateService<ActorStorageRepository, InMemoryActorHotRepository>,
|
||||
> = LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisActorRepository::new(redis_client);
|
||||
let repository = ActorStorageRepository::configured();
|
||||
let hot_repository = InMemoryActorHotRepository::new();
|
||||
ActorHotStateService::new(repository, hot_repository)
|
||||
});
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn hot_service() -> &'static ActorHotStateService<
|
||||
RedisActorRepository<ExtensionRedisClient>,
|
||||
InMemoryActorHotRepository,
|
||||
> {
|
||||
pub(crate) fn hot_service()
|
||||
-> &'static ActorHotStateService<ActorStorageRepository, InMemoryActorHotRepository> {
|
||||
&HOT_ACTOR_SERVICE
|
||||
}
|
||||
|
||||
|
||||
@ -8,37 +8,30 @@ use forge_models::{
|
||||
BankCheckoutContext, BankMutationResult, BankOperationContext, BankPinContext,
|
||||
BankTransferContext, BankTransferResult,
|
||||
};
|
||||
use forge_repositories::{InMemoryBankHotRepository, RedisBankRepository};
|
||||
use forge_repositories::InMemoryBankHotRepository;
|
||||
use forge_services::{BankHotStateService, BankService};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::adapters::ExtensionRedisClient;
|
||||
use crate::enqueue_persistence_task;
|
||||
use crate::helpers::resolve_uid;
|
||||
use crate::log::log;
|
||||
use crate::storage::BankStorageRepository;
|
||||
|
||||
/// Global bank service instance.
|
||||
///
|
||||
/// Lazily initialized singleton combining Redis adapter, repository, and service layers.
|
||||
static BANK_SERVICE: LazyLock<BankService<RedisBankRepository<ExtensionRedisClient>>> =
|
||||
LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisBankRepository::new(redis_client);
|
||||
BankService::new(repository)
|
||||
});
|
||||
static BANK_SERVICE: LazyLock<BankService<BankStorageRepository>> =
|
||||
LazyLock::new(|| BankService::new(BankStorageRepository::configured()));
|
||||
static HOT_BANK_SERVICE: LazyLock<
|
||||
BankHotStateService<RedisBankRepository<ExtensionRedisClient>, InMemoryBankHotRepository>,
|
||||
BankHotStateService<BankStorageRepository, InMemoryBankHotRepository>,
|
||||
> = LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisBankRepository::new(redis_client);
|
||||
let repository = BankStorageRepository::configured();
|
||||
let hot_repository = InMemoryBankHotRepository::new();
|
||||
BankHotStateService::new(repository, hot_repository)
|
||||
});
|
||||
|
||||
pub(crate) fn hot_service() -> &'static BankHotStateService<
|
||||
RedisBankRepository<ExtensionRedisClient>,
|
||||
InMemoryBankHotRepository,
|
||||
> {
|
||||
pub(crate) fn hot_service()
|
||||
-> &'static BankHotStateService<BankStorageRepository, InMemoryBankHotRepository> {
|
||||
&HOT_BANK_SERVICE
|
||||
}
|
||||
|
||||
|
||||
@ -4,37 +4,30 @@
|
||||
|
||||
use arma_rs::{CallContext, Group};
|
||||
use forge_models::Vehicle;
|
||||
use forge_repositories::{InMemoryGarageHotRepository, RedisGarageRepository};
|
||||
use forge_repositories::InMemoryGarageHotRepository;
|
||||
use forge_services::{GarageHotStateService, GarageService};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::adapters::ExtensionRedisClient;
|
||||
use crate::enqueue_persistence_task;
|
||||
use crate::helpers::resolve_uid;
|
||||
use crate::log::log;
|
||||
use crate::storage::GarageStorageRepository;
|
||||
|
||||
/// Global garage service instance.
|
||||
static GARAGE_SERVICE: LazyLock<GarageService<RedisGarageRepository<ExtensionRedisClient>>> =
|
||||
LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisGarageRepository::new(redis_client);
|
||||
GarageService::new(repository)
|
||||
});
|
||||
static GARAGE_SERVICE: LazyLock<GarageService<GarageStorageRepository>> =
|
||||
LazyLock::new(|| GarageService::new(GarageStorageRepository::configured()));
|
||||
static HOT_GARAGE_SERVICE: LazyLock<
|
||||
GarageHotStateService<RedisGarageRepository<ExtensionRedisClient>, InMemoryGarageHotRepository>,
|
||||
GarageHotStateService<GarageStorageRepository, InMemoryGarageHotRepository>,
|
||||
> = LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisGarageRepository::new(redis_client);
|
||||
let repository = GarageStorageRepository::configured();
|
||||
let hot_repository = InMemoryGarageHotRepository::new();
|
||||
GarageHotStateService::new(repository, hot_repository)
|
||||
});
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn hot_service() -> &'static GarageHotStateService<
|
||||
RedisGarageRepository<ExtensionRedisClient>,
|
||||
InMemoryGarageHotRepository,
|
||||
> {
|
||||
pub(crate) fn hot_service()
|
||||
-> &'static GarageHotStateService<GarageStorageRepository, InMemoryGarageHotRepository> {
|
||||
&HOT_GARAGE_SERVICE
|
||||
}
|
||||
|
||||
|
||||
@ -23,7 +23,9 @@ mod log;
|
||||
pub mod org;
|
||||
pub mod phone;
|
||||
pub mod redis;
|
||||
pub mod storage;
|
||||
pub mod store;
|
||||
pub mod surreal;
|
||||
pub mod task;
|
||||
pub mod terrain;
|
||||
pub mod transport;
|
||||
@ -77,10 +79,12 @@ where
|
||||
/// creates the Redis connection pool on the global runtime.
|
||||
fn init() -> Extension {
|
||||
let config = redis::config::load();
|
||||
let storage_backend = config.storage.backend;
|
||||
let ext = Extension::build()
|
||||
.command("version", get_version)
|
||||
.command("status", get_status)
|
||||
.group("redis", redis::group())
|
||||
.group("surreal", surreal::group())
|
||||
.group("actor", actor::group())
|
||||
.group("bank", bank::group())
|
||||
.group("cad", cad::group())
|
||||
@ -104,6 +108,14 @@ fn init() -> Extension {
|
||||
// Spawn initialization tasks for Redis and ICOM
|
||||
// These run asynchronously and don't block extension startup
|
||||
// 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 {
|
||||
redis::initialize(config.redis).await;
|
||||
});
|
||||
|
||||
@ -1,34 +1,27 @@
|
||||
use arma_rs::{CallContext, Group};
|
||||
use forge_models::locker::Item;
|
||||
use forge_repositories::{InMemoryLockerHotRepository, RedisLockerRepository};
|
||||
use forge_repositories::InMemoryLockerHotRepository;
|
||||
use forge_services::{LockerHotStateService, LockerService};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::adapters::ExtensionRedisClient;
|
||||
use crate::enqueue_persistence_task;
|
||||
use crate::helpers::resolve_uid;
|
||||
use crate::log::log;
|
||||
use crate::storage::LockerStorageRepository;
|
||||
|
||||
static LOCKER_SERVICE: LazyLock<LockerService<RedisLockerRepository<ExtensionRedisClient>>> =
|
||||
LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisLockerRepository::new(redis_client);
|
||||
LockerService::new(repository)
|
||||
});
|
||||
static LOCKER_SERVICE: LazyLock<LockerService<LockerStorageRepository>> =
|
||||
LazyLock::new(|| LockerService::new(LockerStorageRepository::configured()));
|
||||
static HOT_LOCKER_SERVICE: LazyLock<
|
||||
LockerHotStateService<RedisLockerRepository<ExtensionRedisClient>, InMemoryLockerHotRepository>,
|
||||
LockerHotStateService<LockerStorageRepository, InMemoryLockerHotRepository>,
|
||||
> = LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisLockerRepository::new(redis_client);
|
||||
let repository = LockerStorageRepository::configured();
|
||||
let hot_repository = InMemoryLockerHotRepository::new();
|
||||
LockerHotStateService::new(repository, hot_repository)
|
||||
});
|
||||
|
||||
pub(crate) fn hot_service() -> &'static LockerHotStateService<
|
||||
RedisLockerRepository<ExtensionRedisClient>,
|
||||
InMemoryLockerHotRepository,
|
||||
> {
|
||||
pub(crate) fn hot_service()
|
||||
-> &'static LockerHotStateService<LockerStorageRepository, InMemoryLockerHotRepository> {
|
||||
&HOT_LOCKER_SERVICE
|
||||
}
|
||||
|
||||
|
||||
@ -11,34 +11,29 @@ use forge_models::{
|
||||
OrgInviteDecisionContext, OrgInviteDecisionResult, OrgInviteRecord, OrgInviteResult,
|
||||
OrgLeaveContext, OrgLeaveResult, OrgRegisterContext,
|
||||
};
|
||||
use forge_repositories::{InMemoryOrgHotRepository, RedisOrgRepository};
|
||||
use forge_repositories::InMemoryOrgHotRepository;
|
||||
use forge_services::{OrgHotStateService, OrgService};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::adapters::ExtensionRedisClient;
|
||||
use crate::enqueue_persistence_task;
|
||||
use crate::log::log;
|
||||
use crate::storage::OrgStorageRepository;
|
||||
|
||||
/// Global organization service instance.
|
||||
///
|
||||
/// Lazily initialized singleton combining Redis adapter, repository, and service layers.
|
||||
static ORG_SERVICE: LazyLock<OrgService<RedisOrgRepository<ExtensionRedisClient>>> =
|
||||
LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisOrgRepository::new(redis_client);
|
||||
OrgService::new(repository)
|
||||
});
|
||||
static ORG_SERVICE: LazyLock<OrgService<OrgStorageRepository>> =
|
||||
LazyLock::new(|| OrgService::new(OrgStorageRepository::configured()));
|
||||
static HOT_ORG_SERVICE: LazyLock<
|
||||
OrgHotStateService<RedisOrgRepository<ExtensionRedisClient>, InMemoryOrgHotRepository>,
|
||||
OrgHotStateService<OrgStorageRepository, InMemoryOrgHotRepository>,
|
||||
> = LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisOrgRepository::new(redis_client);
|
||||
let repository = OrgStorageRepository::configured();
|
||||
let hot_repository = InMemoryOrgHotRepository::new();
|
||||
OrgHotStateService::new(repository, hot_repository)
|
||||
});
|
||||
|
||||
pub(crate) fn hot_service()
|
||||
-> &'static OrgHotStateService<RedisOrgRepository<ExtensionRedisClient>, InMemoryOrgHotRepository> {
|
||||
-> &'static OrgHotStateService<OrgStorageRepository, InMemoryOrgHotRepository> {
|
||||
&HOT_ORG_SERVICE
|
||||
}
|
||||
|
||||
|
||||
@ -3,17 +3,14 @@
|
||||
//! The extension owns phone runtime state for contacts, messages, and emails.
|
||||
//! 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 forge_repositories::RedisPhoneRepository;
|
||||
use forge_services::PhoneStateService;
|
||||
use serde::Serialize;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static PHONE_SERVICE: LazyLock<PhoneStateService<RedisPhoneRepository<ExtensionRedisClient>>> =
|
||||
LazyLock::new(|| {
|
||||
PhoneStateService::new(RedisPhoneRepository::new(ExtensionRedisClient::new()))
|
||||
});
|
||||
static PHONE_SERVICE: LazyLock<PhoneStateService<PhoneStorageRepository>> =
|
||||
LazyLock::new(|| PhoneStateService::new(PhoneStorageRepository::configured()));
|
||||
|
||||
pub fn group() -> Group {
|
||||
Group::new()
|
||||
@ -31,14 +28,16 @@ pub fn group() -> Group {
|
||||
.command("list", list_messages)
|
||||
.command("thread", message_thread)
|
||||
.command("send", send_message)
|
||||
.command("mark_read", mark_message_read),
|
||||
.command("mark_read", mark_message_read)
|
||||
.command("delete", delete_message),
|
||||
)
|
||||
.group(
|
||||
"emails",
|
||||
Group::new()
|
||||
.command("list", list_emails)
|
||||
.command("send", send_email)
|
||||
.command("mark_read", mark_email_read),
|
||||
.command("mark_read", mark_email_read)
|
||||
.command("delete", delete_email),
|
||||
)
|
||||
.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))
|
||||
}
|
||||
|
||||
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(
|
||||
from_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))
|
||||
}
|
||||
|
||||
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 {
|
||||
match PHONE_SERVICE.remove(uid) {
|
||||
Ok(()) => "OK".to_string(),
|
||||
|
||||
@ -12,16 +12,52 @@ static CONFIG_CACHE: OnceLock<Config> = OnceLock::new();
|
||||
/// Main configuration structure for the entire application.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Durable storage backend selector.
|
||||
#[serde(default)]
|
||||
pub storage: StorageConfig,
|
||||
/// Redis configuration with automatic defaults if not specified
|
||||
#[serde(default)]
|
||||
pub redis: RedisConfig,
|
||||
/// SurrealDB configuration with automatic defaults if not specified
|
||||
#[serde(default)]
|
||||
pub surreal: SurrealConfig,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
/// Creates a default configuration with sensible values for development.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
storage: StorageConfig::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 {
|
||||
/// Generates a Redis connection string from the configuration.
|
||||
pub fn connection_string(&self) -> String {
|
||||
@ -121,7 +187,18 @@ pub fn load() -> Config {
|
||||
log("main", "INFO", &format!("Config file found! Loading..."));
|
||||
match toml::from_str::<Config>(&contents) {
|
||||
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(_) => {
|
||||
|
||||
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 forge_models::{VGarage, VehicleCategory};
|
||||
use forge_repositories::{InMemoryVGarageHotRepository, RedisVGarageRepository};
|
||||
use forge_repositories::InMemoryVGarageHotRepository;
|
||||
use forge_services::{VGarageHotStateService, VGarageService};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::adapters::ExtensionRedisClient;
|
||||
use crate::enqueue_persistence_task;
|
||||
use crate::helpers::resolve_uid;
|
||||
use crate::log::log;
|
||||
use crate::storage::VGarageStorageRepository;
|
||||
|
||||
static VGARAGE_SERVICE: LazyLock<VGarageService<RedisVGarageRepository<ExtensionRedisClient>>> =
|
||||
LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisVGarageRepository::new(redis_client);
|
||||
VGarageService::new(repository)
|
||||
});
|
||||
static VGARAGE_SERVICE: LazyLock<VGarageService<VGarageStorageRepository>> =
|
||||
LazyLock::new(|| VGarageService::new(VGarageStorageRepository::configured()));
|
||||
static HOT_VGARAGE_SERVICE: LazyLock<
|
||||
VGarageHotStateService<
|
||||
RedisVGarageRepository<ExtensionRedisClient>,
|
||||
InMemoryVGarageHotRepository,
|
||||
>,
|
||||
VGarageHotStateService<VGarageStorageRepository, InMemoryVGarageHotRepository>,
|
||||
> = LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisVGarageRepository::new(redis_client);
|
||||
let repository = VGarageStorageRepository::configured();
|
||||
let hot_repository = InMemoryVGarageHotRepository::new();
|
||||
VGarageHotStateService::new(repository, hot_repository)
|
||||
});
|
||||
|
||||
pub(crate) fn hot_service() -> &'static VGarageHotStateService<
|
||||
RedisVGarageRepository<ExtensionRedisClient>,
|
||||
InMemoryVGarageHotRepository,
|
||||
> {
|
||||
pub(crate) fn hot_service()
|
||||
-> &'static VGarageHotStateService<VGarageStorageRepository, InMemoryVGarageHotRepository> {
|
||||
&HOT_VGARAGE_SERVICE
|
||||
}
|
||||
|
||||
|
||||
@ -1,36 +1,26 @@
|
||||
use arma_rs::{CallContext, Group};
|
||||
use forge_models::{EquipmentCategory, VLocker};
|
||||
use forge_repositories::{InMemoryVLockerHotRepository, RedisVLockerRepository};
|
||||
use forge_repositories::InMemoryVLockerHotRepository;
|
||||
use forge_services::{VLockerHotStateService, VLockerService};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::adapters::ExtensionRedisClient;
|
||||
use crate::enqueue_persistence_task;
|
||||
use crate::helpers::resolve_uid;
|
||||
use crate::log::log;
|
||||
use crate::storage::VLockerStorageRepository;
|
||||
|
||||
static VLOCKER_SERVICE: LazyLock<VLockerService<RedisVLockerRepository<ExtensionRedisClient>>> =
|
||||
LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisVLockerRepository::new(redis_client);
|
||||
VLockerService::new(repository)
|
||||
});
|
||||
static VLOCKER_SERVICE: LazyLock<VLockerService<VLockerStorageRepository>> =
|
||||
LazyLock::new(|| VLockerService::new(VLockerStorageRepository::configured()));
|
||||
static HOT_VLOCKER_SERVICE: LazyLock<
|
||||
VLockerHotStateService<
|
||||
RedisVLockerRepository<ExtensionRedisClient>,
|
||||
InMemoryVLockerHotRepository,
|
||||
>,
|
||||
VLockerHotStateService<VLockerStorageRepository, InMemoryVLockerHotRepository>,
|
||||
> = LazyLock::new(|| {
|
||||
let redis_client = ExtensionRedisClient::new();
|
||||
let repository = RedisVLockerRepository::new(redis_client);
|
||||
let repository = VLockerStorageRepository::configured();
|
||||
let hot_repository = InMemoryVLockerHotRepository::new();
|
||||
VLockerHotStateService::new(repository, hot_repository)
|
||||
});
|
||||
|
||||
pub(crate) fn hot_service() -> &'static VLockerHotStateService<
|
||||
RedisVLockerRepository<ExtensionRedisClient>,
|
||||
InMemoryVLockerHotRepository,
|
||||
> {
|
||||
pub(crate) fn hot_service()
|
||||
-> &'static VLockerHotStateService<VLockerStorageRepository, InMemoryVLockerHotRepository> {
|
||||
&HOT_VLOCKER_SERVICE
|
||||
}
|
||||
|
||||
|
||||
@ -13,10 +13,12 @@ pub trait PhoneRepository: Send + Sync {
|
||||
fn append_message(&self, uid: &str, message: PhoneMessage) -> Result<(), 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 delete_message(&self, uid: &str, message_id: &str) -> Result<bool, String>;
|
||||
|
||||
fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), 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 delete_email(&self, uid: &str, email_id: &str) -> Result<bool, String>;
|
||||
|
||||
fn next_sequence(&self) -> Result<u64, String>;
|
||||
}
|
||||
@ -146,6 +148,19 @@ impl PhoneRepository for InMemoryPhoneRepository {
|
||||
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> {
|
||||
self.state
|
||||
.write()
|
||||
@ -193,6 +208,19 @@ impl PhoneRepository for InMemoryPhoneRepository {
|
||||
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> {
|
||||
let mut state = self
|
||||
.state
|
||||
@ -476,6 +504,38 @@ impl<C: RedisClient> PhoneRepository for RedisPhoneRepository<C> {
|
||||
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> {
|
||||
self.save_email_record(&email)?;
|
||||
self.client
|
||||
@ -518,6 +578,23 @@ impl<C: RedisClient> PhoneRepository for RedisPhoneRepository<C> {
|
||||
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> {
|
||||
let value = self.client.incr_key(Self::sequence_key(), 1)?;
|
||||
u64::try_from(value).map_err(|_| "Phone sequence overflowed.".to_string())
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
use forge_models::{PhoneEmail, PhoneMessage, PhonePayload};
|
||||
use forge_repositories::PhoneRepository;
|
||||
|
||||
const FIELD_COMMANDER_UID: &str = "field_commander";
|
||||
|
||||
pub struct PhoneStateService<R: PhoneRepository> {
|
||||
repository: R,
|
||||
}
|
||||
@ -13,15 +15,14 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
||||
pub fn init(&self, uid: String) -> Result<PhonePayload, String> {
|
||||
let uid = Self::validate_uid(uid)?;
|
||||
self.repository.init(&uid)?;
|
||||
self.repository.add_contact(&uid, &uid)?;
|
||||
self.repository.add_contact(&uid, FIELD_COMMANDER_UID)?;
|
||||
self.payload_for(&uid)
|
||||
}
|
||||
|
||||
pub fn add_contact(&self, uid: String, contact_uid: String) -> Result<bool, String> {
|
||||
let uid = Self::validate_uid(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)
|
||||
}
|
||||
|
||||
@ -46,6 +47,11 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
||||
let from_uid = Self::validate_uid(from_uid)?;
|
||||
let to_uid = Self::validate_uid(to_uid)?;
|
||||
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 id = format!(
|
||||
"phone-message:{}:{}:{}",
|
||||
@ -63,7 +69,9 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@ -96,6 +104,12 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
||||
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(
|
||||
&self,
|
||||
from_uid: String,
|
||||
@ -106,8 +120,13 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
||||
) -> Result<PhoneEmail, String> {
|
||||
let from_uid = Self::validate_uid(from_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.")?;
|
||||
Self::validate_send_target(
|
||||
&from_uid,
|
||||
&to_uid,
|
||||
"Field Commander cannot receive player emails.",
|
||||
)?;
|
||||
let timestamp = Self::parse_timestamp(timestamp);
|
||||
let id = format!(
|
||||
"phone-email:{}:{}:{}",
|
||||
@ -117,7 +136,7 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
||||
);
|
||||
let record = PhoneEmail {
|
||||
id,
|
||||
from: from_uid,
|
||||
from: from_uid.clone(),
|
||||
to: to_uid.clone(),
|
||||
subject,
|
||||
body,
|
||||
@ -126,6 +145,10 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@ -140,6 +163,12 @@ impl<R: PhoneRepository> PhoneStateService<R> {
|
||||
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> {
|
||||
let uid = Self::validate_uid(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 {
|
||||
timestamp.trim().parse::<f64>().unwrap_or_default()
|
||||
}
|
||||
@ -212,13 +258,180 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_cannot_reference_self() {
|
||||
fn contact_can_reference_self_for_owner_card() {
|
||||
let service = PhoneStateService::new(InMemoryPhoneRepository::new());
|
||||
|
||||
assert!(
|
||||
service
|
||||
.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()
|
||||
);
|
||||
}
|
||||
|
||||
#[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