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:
Jacob Schmidt 2026-04-11 22:36:11 -05:00
parent a8415eb1fd
commit 4532e7b73d
45 changed files with 3324 additions and 312 deletions

View File

@ -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);

View File

@ -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", []];

View File

@ -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 */

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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(' - '))
)
);
}
}
}

View File

@ -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'
)

View File

@ -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'
)
);
}
}

View File

@ -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

View File

@ -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>',

View File

@ -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
);
}
}

View File

@ -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 {
)
);
}
}
}

View File

@ -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;

View File

@ -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;
}

View File

@ -31,7 +31,9 @@ class HomeIndicator extends Component {
globalState.setState({
currentApp: 'home',
selectedConversation: null,
selectedConversationRaw: null,
selectedContact: null,
showMessageContactPicker: false,
showModal: false,
});
}

View File

@ -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)
)
);
}

View File

@ -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,
});
}

View File

@ -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;
}
});

View File

@ -29,6 +29,7 @@ const initialAppState = {
// UI state
selectedContact: null,
selectedConversation: null,
showMessageContactPicker: false,
newMessage: '',
currentUid: null,

View File

@ -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;

View File

@ -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);
}

View File

@ -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 @@
}
}
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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]]];

View File

@ -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 {

View File

@ -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", "", [""]]];

View File

@ -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 { [] };

View File

@ -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", "", [""]]];

View File

@ -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 }

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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;
});

View File

@ -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
}

View File

@ -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
}

View File

@ -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(),

View File

@ -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(_) => {

File diff suppressed because it is too large Load Diff

View 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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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())

View File

@ -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
);
}
}