1472 lines
51 KiB
C++
1472 lines
51 KiB
C++
#include "phone_data.h"
|
|
#include "../sxmo-dwm/dwm_plugin.h"
|
|
|
|
#include <X11/Xatom.h>
|
|
#include <X11/Xft/Xft.h>
|
|
#include <X11/Xutil.h>
|
|
#include <pango/pangoxft.h>
|
|
#include <poll.h>
|
|
#include <sys/wait.h>
|
|
#include <clocale>
|
|
#include <mutex>
|
|
#include <thread>
|
|
|
|
namespace {
|
|
|
|
using namespace bananarchy;
|
|
|
|
volatile sig_atomic_t gRunning = 1;
|
|
|
|
void handleSignal(int) {
|
|
gRunning = 0;
|
|
}
|
|
|
|
enum class ViewMode {
|
|
Threads = 0,
|
|
ThreadView = 1,
|
|
Compose = 2,
|
|
PickContact = 3,
|
|
Options = 4,
|
|
ConfirmDelete = 5,
|
|
};
|
|
|
|
enum class TextCaseMode {
|
|
Title = 0, // Abc
|
|
Upper = 1, // ABC
|
|
Lower = 2, // abc
|
|
Num = 3, // 123
|
|
};
|
|
|
|
const char* kMultiTapSms[10] = {
|
|
" 0\n", // 0
|
|
".,?!'-:;()@/1", // 1
|
|
"abc2", // 2
|
|
"def3", // 3
|
|
"ghi4", // 4
|
|
"jkl5", // 5
|
|
"mno6", // 6
|
|
"pqrs7", // 7
|
|
"tuv8", // 8
|
|
"wxyz9" // 9
|
|
};
|
|
|
|
void popUtf8Char(std::string& s) {
|
|
if (s.empty()) {
|
|
return;
|
|
}
|
|
while (!s.empty() && (static_cast<unsigned char>(s.back()) & 0xC0) == 0x80) {
|
|
s.pop_back();
|
|
}
|
|
if (!s.empty()) {
|
|
s.pop_back();
|
|
}
|
|
}
|
|
|
|
int runSmsApp(int argc, char** argv) {
|
|
gRunning = 1;
|
|
std::setlocale(LC_ALL, "");
|
|
XSetLocaleModifiers("");
|
|
|
|
bool isPreload = false;
|
|
std::string initArg;
|
|
for (int i = 1; i < argc; ++i) {
|
|
if (!argv[i]) continue;
|
|
if (std::strcmp(argv[i], "--preload") == 0) {
|
|
isPreload = true;
|
|
} else if (std::strcmp(argv[i], "sendtextmenu") == 0 ||
|
|
std::strcmp(argv[i], "conversationloop") == 0) {
|
|
if (i + 1 < argc && argv[i + 1]) {
|
|
initArg = std::string("send:") + argv[++i];
|
|
} else {
|
|
initArg = "send:";
|
|
}
|
|
} else if (std::strcmp(argv[i], "tailtextlog") == 0) {
|
|
if (i + 1 < argc && argv[i + 1]) {
|
|
initArg = std::string("thread:") + argv[++i];
|
|
}
|
|
} else if ( std::strcmp(argv[i], "readtextmenu") == 0 ) {
|
|
initArg.clear();
|
|
} else if (initArg.empty()) {
|
|
initArg = argv[i];
|
|
}
|
|
}
|
|
|
|
std::string daemonPidPath = getPluginDaemonPidPath("sms");
|
|
std::string activePidPath = getRuntimeFile("xsms.pid");
|
|
|
|
if (isPluginDaemonAlive("sms")) {
|
|
if (!isPreload) {
|
|
sendPluginCommand("sms", initArg.empty() ? "OPEN" : ("OPEN " + initArg));
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
writeIntFile(daemonPidPath, static_cast<int>(::getpid()));
|
|
int fifoFd = setupPluginDaemonFifo("sms");
|
|
|
|
int notifyPipe[2] = {-1, -1};
|
|
if (::pipe(notifyPipe) == 0) {
|
|
::fcntl(notifyPipe[0], F_SETFL, O_NONBLOCK | O_CLOEXEC);
|
|
::fcntl(notifyPipe[1], F_SETFL, O_NONBLOCK | O_CLOEXEC);
|
|
}
|
|
|
|
struct sigaction sa {};
|
|
sa.sa_handler = handleSignal;
|
|
sigemptyset(&sa.sa_mask);
|
|
sigaction(SIGINT, &sa, nullptr);
|
|
sigaction(SIGTERM, &sa, nullptr);
|
|
|
|
Display* dpy = XOpenDisplay(nullptr);
|
|
if (!dpy) {
|
|
if (fifoFd >= 0) ::close(fifoFd);
|
|
if (notifyPipe[0] >= 0) ::close(notifyPipe[0]);
|
|
if (notifyPipe[1] >= 0) ::close(notifyPipe[1]);
|
|
::unlink(daemonPidPath.c_str());
|
|
return 1;
|
|
}
|
|
|
|
int screen = DefaultScreen(dpy);
|
|
Window root = RootWindow(dpy, screen);
|
|
Visual* visual = DefaultVisual(dpy, screen);
|
|
Colormap cmap = DefaultColormap(dpy, screen);
|
|
|
|
ThemeSpec initSpec = loadThemeSpec(dpy, screen);
|
|
|
|
XSetWindowAttributes swa {};
|
|
swa.override_redirect = True;
|
|
swa.background_pixel = initSpec.bgMain;
|
|
swa.border_pixel = initSpec.selBg;
|
|
swa.event_mask = ExposureMask | KeyPressMask | KeyReleaseMask |
|
|
VisibilityChangeMask | StructureNotifyMask;
|
|
|
|
Window win = XCreateWindow(
|
|
dpy, root, kWinX, kWinY, kWinWidth, kWinHeight, 0,
|
|
DefaultDepth(dpy, screen), CopyFromParent, visual,
|
|
CWOverrideRedirect | CWBackPixel | CWBorderPixel | CWEventMask, &swa);
|
|
|
|
XClassHint ch {};
|
|
char resName[] = "xsms";
|
|
char resClass[] = "XSms";
|
|
ch.res_name = resName;
|
|
ch.res_class = resClass;
|
|
XSetClassHint(dpy, win, &ch);
|
|
XStoreName(dpy, win, "Messages");
|
|
|
|
Pixmap pix = XCreatePixmap(dpy, win, kWinWidth, kWinHeight,
|
|
DefaultDepth(dpy, screen));
|
|
GC gc = XCreateGC(dpy, pix, 0, nullptr);
|
|
XftDraw* xftDraw = XftDrawCreate(dpy, pix, visual, cmap);
|
|
|
|
PluginTheme pt;
|
|
pt.init(dpy, screen, visual, cmap, win);
|
|
auto& layoutMain = pt.layoutMain;
|
|
auto& layoutSmall = pt.layoutSmall;
|
|
auto& layoutIcon = pt.layoutIcon;
|
|
auto& colWhite = pt.colWhite;
|
|
auto& colLight = pt.colLight;
|
|
auto& colMuted = pt.colMuted;
|
|
auto& colYellow = pt.colYellow;
|
|
auto& colGreen = pt.colGreen;
|
|
auto& colBlue = pt.colBlue;
|
|
auto& colRed = pt.colRed;
|
|
|
|
ContactBook book;
|
|
std::vector<SmsThread> allThreads;
|
|
std::vector<SmsThread> filteredThreads;
|
|
std::vector<SmsMessage> threadMessages;
|
|
std::vector<ContactEntry> pickerContacts;
|
|
|
|
FileStamp contactsStamp;
|
|
FileStamp modemLogStamp;
|
|
bool loadedOnce = false;
|
|
|
|
ViewMode mode = ViewMode::Threads;
|
|
ViewMode prevMode = ViewMode::Threads;
|
|
std::string searchQuery;
|
|
int selectedThread = 0;
|
|
int selectedMsg = 0;
|
|
int selectedOption = 0;
|
|
int selectedPicker = 0;
|
|
int confirmSelected = 0;
|
|
|
|
std::string activeNumber;
|
|
std::string composeRecipient;
|
|
std::string composeText;
|
|
int composeField = 1; // 0 = To, 1 = Message body, 2 = Send button, 3 = Save Draft
|
|
TextCaseMode caseMode = TextCaseMode::Title;
|
|
int lastTapDigit = -1;
|
|
int lastTapCycle = 0;
|
|
int64_t lastTapMs = 0;
|
|
bool zeroHeld = false;
|
|
int64_t zeroPressMs = 0;
|
|
|
|
std::mutex sendMutex;
|
|
bool sendingInProgress = false;
|
|
std::string statusBanner;
|
|
int64_t statusBannerUntilMs = 0;
|
|
|
|
bool visible = false;
|
|
int64_t openStartMs = 0;
|
|
int64_t startupIgnoreUntilMs = 0;
|
|
bool initialSoftkeysReleased = true;
|
|
int softkeyPending = 0;
|
|
int64_t softkeyDeadlineMs = 0;
|
|
|
|
struct OptionEntry {
|
|
int id;
|
|
std::string icon;
|
|
std::string label;
|
|
};
|
|
std::vector<OptionEntry> optionsList;
|
|
|
|
auto updateFilter = [&]() {
|
|
filteredThreads.clear();
|
|
if (searchQuery.empty()) {
|
|
filteredThreads = allThreads;
|
|
} else {
|
|
std::string qNorm = normalizePhone(searchQuery);
|
|
std::string qLower = toLowerStr(searchQuery);
|
|
for (const auto& t : allThreads) {
|
|
bool match =
|
|
(!qLower.empty() && t.lowerName.find(qLower) != std::string::npos) ||
|
|
(!qNorm.empty() && t.t9Name.find(qNorm) != std::string::npos) ||
|
|
(!qNorm.empty() && t.normNumber.find(qNorm) != std::string::npos) ||
|
|
(!qLower.empty() &&
|
|
toLowerStr(t.lastPreview).find(qLower) != std::string::npos);
|
|
if (match) {
|
|
filteredThreads.push_back(t);
|
|
}
|
|
}
|
|
}
|
|
if (selectedThread >= static_cast<int>(filteredThreads.size())) {
|
|
selectedThread =
|
|
std::max(0, static_cast<int>(filteredThreads.size()) - 1);
|
|
}
|
|
};
|
|
|
|
auto reloadAllData = [&](bool force = false) {
|
|
bool cChanged = contactsStamp.checkAndUpdate(getContactsFilePath());
|
|
bool lChanged = modemLogStamp.checkAndUpdate(getModemLogFilePath());
|
|
if (force || cChanged || lChanged || !loadedOnce) {
|
|
loadedOnce = true;
|
|
book.load();
|
|
allThreads = loadSmsThreads(book);
|
|
updateFilter();
|
|
if (!activeNumber.empty()) {
|
|
threadMessages = loadSmsThreadMessages(activeNumber);
|
|
if (selectedMsg >= static_cast<int>(threadMessages.size())) {
|
|
selectedMsg =
|
|
std::max(0, static_cast<int>(threadMessages.size()) - 1);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
reloadAllData(true);
|
|
|
|
auto openThreadView = [&](const std::string& num) {
|
|
activeNumber = trimStr(num);
|
|
threadMessages = loadSmsThreadMessages(activeNumber);
|
|
selectedMsg = std::max(0, static_cast<int>(threadMessages.size()) - 1);
|
|
mode = ViewMode::ThreadView;
|
|
};
|
|
|
|
auto openComposeView = [&](const std::string& num, const std::string& prefill = "") {
|
|
prevMode = mode;
|
|
composeRecipient = trimStr(num);
|
|
if (!prefill.empty()) {
|
|
composeText = prefill;
|
|
} else if (!composeRecipient.empty()) {
|
|
composeText = loadSmsDraft(composeRecipient);
|
|
} else {
|
|
composeText.clear();
|
|
}
|
|
composeField = composeRecipient.empty() ? 0 : 1;
|
|
lastTapDigit = -1;
|
|
mode = ViewMode::Compose;
|
|
};
|
|
|
|
auto buildOptions = [&]() {
|
|
optionsList.clear();
|
|
if (mode == ViewMode::Threads) {
|
|
optionsList.push_back({1, kIconPlus, "New Message"});
|
|
if (!filteredThreads.empty()) {
|
|
optionsList.push_back({2, kIconEye, "Open Conversation"});
|
|
optionsList.push_back({3, kIconSend, "Reply to Selected"});
|
|
optionsList.push_back({4, kIconPhone, "Call Contact"});
|
|
optionsList.push_back({5, kIconUser, "Open in Contacts"});
|
|
optionsList.push_back({6, kIconTrash, "Delete Conversation"});
|
|
}
|
|
} else if (mode == ViewMode::ThreadView) {
|
|
optionsList.push_back({3, kIconSend, "Reply / Compose"});
|
|
optionsList.push_back({4, kIconPhone, "Call Contact"});
|
|
if (!threadMessages.empty()) {
|
|
optionsList.push_back({7, kIconCopy, "Forward Selected Msg"});
|
|
}
|
|
optionsList.push_back({5, kIconUser, "Save / View Contact"});
|
|
optionsList.push_back({6, kIconTrash, "Delete Conversation"});
|
|
}
|
|
selectedOption = 0;
|
|
};
|
|
|
|
auto startAsyncSendSms = [&](const std::string& rawRecipient,
|
|
const std::string& body) {
|
|
std::string recipient = normalizePhone(rawRecipient);
|
|
if (recipient.empty() || trimStr(body).empty()) {
|
|
statusBanner = "Set recipient and message";
|
|
statusBannerUntilMs = nowMs() + 2000;
|
|
return;
|
|
}
|
|
{
|
|
std::lock_guard<std::mutex> lk(sendMutex);
|
|
if (sendingInProgress) {
|
|
return;
|
|
}
|
|
sendingInProgress = true;
|
|
}
|
|
saveSmsDraft(recipient, body);
|
|
statusBanner = "Sending SMS to " + recipient + "...";
|
|
statusBannerUntilMs = nowMs() + 15000;
|
|
|
|
int writePipe = notifyPipe[1];
|
|
std::thread([recipient, body, writePipe, &sendMutex, &sendingInProgress,
|
|
&statusBanner, &statusBannerUntilMs]() {
|
|
std::string draftPath =
|
|
getModemLogDirPath() + "/" + recipient + "/draft.txt";
|
|
ContactBook::ensureParentDir(draftPath);
|
|
{
|
|
std::ofstream out(draftPath, std::ios::trunc);
|
|
out << body << "\n";
|
|
}
|
|
|
|
int rc = -1;
|
|
pid_t pid = ::fork();
|
|
if (pid == 0) {
|
|
int nullfd = ::open("/dev/null", O_RDWR);
|
|
if (nullfd >= 0) {
|
|
::dup2(nullfd, STDIN_FILENO);
|
|
::dup2(nullfd, STDOUT_FILENO);
|
|
::dup2(nullfd, STDERR_FILENO);
|
|
if (nullfd > 2) ::close(nullfd);
|
|
}
|
|
execlp("sxmo_modemsendsms.sh", "sxmo_modemsendsms.sh",
|
|
recipient.c_str(), "-f", draftPath.c_str(),
|
|
static_cast<char*>(nullptr));
|
|
execl("/usr/bin/sxmo_modemsendsms.sh", "sxmo_modemsendsms.sh",
|
|
recipient.c_str(), "-f", draftPath.c_str(),
|
|
static_cast<char*>(nullptr));
|
|
_exit(127);
|
|
} else if (pid > 0) {
|
|
int status = 0;
|
|
::waitpid(pid, &status, 0);
|
|
if (WIFEXITED(status)) {
|
|
rc = WEXITSTATUS(status);
|
|
}
|
|
}
|
|
|
|
// If modem send failed (e.g. offline/no SIM), still log locally if user
|
|
// wants local record or keep draft so nothing is lost
|
|
if (rc == 0) {
|
|
removeSmsDraft(recipient);
|
|
} else {
|
|
// Record in local thread log as Sent (or Draft) so conversation is visible
|
|
time_t nowSec = std::time(nullptr);
|
|
struct tm tmNow {};
|
|
localtime_r(&nowSec, &tmNow);
|
|
char tbuf[32];
|
|
std::strftime(tbuf, sizeof(tbuf), "%Y-%m-%dT%H:%M:%S%z", &tmNow);
|
|
std::string smsPath =
|
|
getModemLogDirPath() + "/" + recipient + "/sms.txt";
|
|
ContactBook::ensureParentDir(smsPath);
|
|
std::ofstream smsOut(smsPath, std::ios::app);
|
|
if (smsOut.is_open()) {
|
|
smsOut << "Sent at " << tbuf << ":\n" << body << "\n\n";
|
|
}
|
|
std::ofstream logOut(getModemLogFilePath(), std::ios::app);
|
|
if (logOut.is_open()) {
|
|
logOut << tbuf << "\tsent_txt\t" << recipient << "\t"
|
|
<< body.size() << " chars\n";
|
|
}
|
|
removeSmsDraft(recipient);
|
|
}
|
|
|
|
{
|
|
std::lock_guard<std::mutex> lk(sendMutex);
|
|
sendingInProgress = false;
|
|
statusBanner = (rc == 0) ? "SMS Sent!" : "Saved to Sent (modem offline)";
|
|
statusBannerUntilMs = nowMs() + 2200;
|
|
}
|
|
if (writePipe >= 0) {
|
|
char c = 'S';
|
|
(void)::write(writePipe, &c, 1);
|
|
}
|
|
}).detach();
|
|
};
|
|
|
|
auto drawPango = [&](PangoLayout* layout, XftColor* color, int x, int y, int h,
|
|
const std::string& text, int maxW = -1) {
|
|
pango_layout_set_text(layout, text.c_str(), static_cast<int>(text.size()));
|
|
if (maxW > 0) {
|
|
pango_layout_set_width(layout, maxW * PANGO_SCALE);
|
|
pango_layout_set_ellipsize(layout, PANGO_ELLIPSIZE_END);
|
|
} else {
|
|
pango_layout_set_width(layout, -1);
|
|
pango_layout_set_ellipsize(layout, PANGO_ELLIPSIZE_NONE);
|
|
}
|
|
int tw = 0, th = 0;
|
|
pango_layout_get_pixel_size(layout, &tw, &th);
|
|
int dy = y + std::max(0, (h - th) / 2);
|
|
pango_xft_render_layout(xftDraw, color, layout, x * PANGO_SCALE,
|
|
dy * PANGO_SCALE);
|
|
return tw;
|
|
};
|
|
|
|
auto drawSoftkeyBar = [&](const std::string& leftLabel,
|
|
const std::string& centerLabel,
|
|
const std::string& rightLabel) {
|
|
const int barY = kWinHeight - 26;
|
|
XSetForeground(dpy, gc, pt.spec.bgFooter);
|
|
XFillRectangle(dpy, pix, gc, 0, barY, kWinWidth, 26);
|
|
XSetForeground(dpy, gc, pt.spec.divider);
|
|
XDrawLine(dpy, pix, gc, 0, barY, kWinWidth, barY);
|
|
|
|
drawPango(layoutMain, &colBlue, 6, barY, 26, leftLabel);
|
|
|
|
pango_layout_set_width(layoutMain, -1);
|
|
pango_layout_set_text(layoutMain, centerLabel.c_str(),
|
|
static_cast<int>(centerLabel.size()));
|
|
int cw = 0, chh = 0;
|
|
pango_layout_get_pixel_size(layoutMain, &cw, &chh);
|
|
drawPango(layoutMain, &colWhite, (kWinWidth - cw) / 2, barY, 26,
|
|
centerLabel);
|
|
|
|
pango_layout_set_width(layoutMain, -1);
|
|
pango_layout_set_text(layoutMain, rightLabel.c_str(),
|
|
static_cast<int>(rightLabel.size()));
|
|
int rw = 0, rhh = 0;
|
|
pango_layout_get_pixel_size(layoutMain, &rw, &rhh);
|
|
drawPango(layoutMain, &colLight, kWinWidth - rw - 6, barY, 26, rightLabel);
|
|
};
|
|
|
|
auto drawUI = [&]() {
|
|
if (!visible) return;
|
|
pt.checkAndReload();
|
|
|
|
XSetForeground(dpy, gc, pt.spec.bgMain);
|
|
XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, kWinHeight);
|
|
|
|
if (mode == ViewMode::Threads) {
|
|
XSetForeground(dpy, gc, pt.spec.bgHeader);
|
|
XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 38);
|
|
XSetForeground(dpy, gc, pt.spec.border);
|
|
XDrawLine(dpy, pix, gc, 0, 37, kWinWidth, 37);
|
|
|
|
if (searchQuery.empty()) {
|
|
drawPango(layoutIcon, &colYellow, 8, 2, 34, kIconSms);
|
|
drawPango(layoutMain, &colWhite, 30, 2, 34, "Messages", 145);
|
|
std::string cnt = "[" + std::to_string(allThreads.size()) + "]";
|
|
pango_layout_set_width(layoutSmall, -1);
|
|
pango_layout_set_text(layoutSmall, cnt.c_str(),
|
|
static_cast<int>(cnt.size()));
|
|
int cw = 0, chh = 0;
|
|
pango_layout_get_pixel_size(layoutSmall, &cw, &chh);
|
|
drawPango(layoutSmall, &colMuted, kWinWidth - cw - 8, 2, 34, cnt);
|
|
} else {
|
|
drawPango(layoutIcon, &colBlue, 8, 2, 34, kIconSearch);
|
|
drawPango(layoutMain, &colYellow, 30, 2, 34, searchQuery, 155);
|
|
std::string cnt = std::to_string(filteredThreads.size());
|
|
pango_layout_set_width(layoutSmall, -1);
|
|
pango_layout_set_text(layoutSmall, cnt.c_str(),
|
|
static_cast<int>(cnt.size()));
|
|
int cw = 0, chh = 0;
|
|
pango_layout_get_pixel_size(layoutSmall, &cw, &chh);
|
|
drawPango(layoutSmall, &colMuted, kWinWidth - cw - 8, 2, 34, cnt);
|
|
}
|
|
|
|
const int listY = 38;
|
|
const int rowH = 38;
|
|
const int visibleRows = 6;
|
|
|
|
if (filteredThreads.empty()) {
|
|
drawPango(layoutMain, &colMuted, 16, 110, 24,
|
|
searchQuery.empty() ? "No messages yet" : "No matches", 208);
|
|
drawPango(layoutSmall, &colBlue, 16, 138, 20,
|
|
"Press Left Softkey for New SMS", 208);
|
|
} else {
|
|
int scroll = 0;
|
|
if (selectedThread >= visibleRows) {
|
|
scroll = selectedThread - visibleRows + 1;
|
|
}
|
|
for (int i = 0; i < visibleRows; ++i) {
|
|
int idx = scroll + i;
|
|
if (idx >= static_cast<int>(filteredThreads.size())) break;
|
|
const auto& th = filteredThreads[idx];
|
|
int ry = listY + i * rowH;
|
|
bool isSel = (idx == selectedThread);
|
|
|
|
if (isSel) {
|
|
XSetForeground(dpy, gc, pt.spec.selBg);
|
|
XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH - 1);
|
|
XSetForeground(dpy, gc, pt.spec.accentAlt);
|
|
XDrawLine(dpy, pix, gc, 0, ry, kWinWidth, ry);
|
|
} else {
|
|
XSetForeground(dpy, gc,
|
|
(i % 2 == 0) ? pt.spec.bgAltEven : pt.spec.bgAltOdd);
|
|
XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH - 1);
|
|
}
|
|
|
|
const char* ic = th.hasDraft
|
|
? kIconEdit
|
|
: (th.lastIncoming ? kIconInCall : kIconOutCall);
|
|
XftColor* icCol = th.hasDraft
|
|
? &colYellow
|
|
: (th.lastIncoming ? &colGreen : &colBlue);
|
|
drawPango(layoutIcon, icCol, 6, ry + 1, 20, ic);
|
|
|
|
std::string title =
|
|
th.contactName.empty() ? th.number : th.contactName;
|
|
drawPango(layoutMain, &colWhite, 26, ry + 1, 20, title, 128);
|
|
|
|
if (!th.lastDisplayTime.empty()) {
|
|
pango_layout_set_width(layoutSmall, -1);
|
|
pango_layout_set_text(layoutSmall, th.lastDisplayTime.c_str(),
|
|
static_cast<int>(th.lastDisplayTime.size()));
|
|
int tw = 0, thh = 0;
|
|
pango_layout_get_pixel_size(layoutSmall, &tw, &thh);
|
|
drawPango(layoutSmall, isSel ? &colLight : &colMuted,
|
|
kWinWidth - tw - 6, ry + 2, 18, th.lastDisplayTime);
|
|
}
|
|
|
|
drawPango(layoutSmall, isSel ? &colLight : &colMuted, 26, ry + 20, 16,
|
|
th.lastPreview, 206);
|
|
}
|
|
}
|
|
|
|
drawSoftkeyBar("Options",
|
|
filteredThreads.empty() ? "New SMS" : "Open",
|
|
searchQuery.empty() ? "Close" : "Clear");
|
|
|
|
} else if (mode == ViewMode::ThreadView) {
|
|
XSetForeground(dpy, gc, pt.spec.bgHeader);
|
|
XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 36);
|
|
XSetForeground(dpy, gc, pt.spec.border);
|
|
XDrawLine(dpy, pix, gc, 0, 35, kWinWidth, 35);
|
|
|
|
std::string cname = book.resolveName(activeNumber);
|
|
std::string title = cname.empty() ? activeNumber : cname;
|
|
drawPango(layoutIcon, &colYellow, 6, 1, 18, kIconSms);
|
|
drawPango(layoutMain, &colWhite, 26, 0, 20, title, 160);
|
|
std::string sub = cname.empty()
|
|
? (std::to_string(threadMessages.size()) + " msgs")
|
|
: activeNumber;
|
|
drawPango(layoutSmall, &colMuted, 26, 19, 15, sub, 204);
|
|
|
|
if (threadMessages.empty()) {
|
|
drawPango(layoutMain, &colMuted, 16, 110, 24, "No messages in thread",
|
|
208);
|
|
drawPango(layoutSmall, &colBlue, 16, 138, 20,
|
|
"Press OK to write a message", 208);
|
|
} else {
|
|
// Measure and render message cards from bottom up or around selectedMsg
|
|
const int areaTop = 38;
|
|
const int areaBot = kWinHeight - 28;
|
|
const int areaH = areaBot - areaTop;
|
|
|
|
struct CardGeom {
|
|
int idx;
|
|
int h;
|
|
std::string header;
|
|
std::string body;
|
|
bool incoming;
|
|
};
|
|
std::vector<CardGeom> cards;
|
|
cards.reserve(threadMessages.size());
|
|
|
|
for (size_t i = 0; i < threadMessages.size(); ++i) {
|
|
const auto& m = threadMessages[i];
|
|
CardGeom cg;
|
|
cg.idx = static_cast<int>(i);
|
|
cg.incoming = m.incoming;
|
|
cg.header = (m.incoming ? "Recv " : "Sent ") + m.displayTime;
|
|
cg.body = m.text;
|
|
|
|
pango_layout_set_width(layoutSmall, 204 * PANGO_SCALE);
|
|
pango_layout_set_wrap(layoutSmall, PANGO_WRAP_WORD_CHAR);
|
|
pango_layout_set_ellipsize(layoutSmall, PANGO_ELLIPSIZE_NONE);
|
|
pango_layout_set_text(layoutSmall, cg.body.c_str(),
|
|
static_cast<int>(cg.body.size()));
|
|
int bw = 0, bh = 0;
|
|
pango_layout_get_pixel_size(layoutSmall, &bw, &bh);
|
|
cg.h = std::max(38, bh + 22);
|
|
cards.push_back(cg);
|
|
}
|
|
|
|
// Determine visible slice ending at selectedMsg (or covering selectedMsg)
|
|
int endIdx = std::clamp(selectedMsg, 0,
|
|
static_cast<int>(cards.size()) - 1);
|
|
int startIdx = endIdx;
|
|
int usedH = cards[endIdx].h;
|
|
while (startIdx > 0 && usedH + cards[startIdx - 1].h + 4 <= areaH) {
|
|
--startIdx;
|
|
usedH += cards[startIdx].h + 4;
|
|
}
|
|
while (endIdx + 1 < static_cast<int>(cards.size()) &&
|
|
usedH + cards[endIdx + 1].h + 4 <= areaH) {
|
|
++endIdx;
|
|
usedH += cards[endIdx].h + 4;
|
|
}
|
|
|
|
int curY = areaTop + 2;
|
|
for (int i = startIdx; i <= endIdx; ++i) {
|
|
const auto& cg = cards[i];
|
|
bool isSel = (cg.idx == selectedMsg);
|
|
int cardX = cg.incoming ? 4 : 14;
|
|
int cardW = kWinWidth - 18;
|
|
int cardH = std::min(cg.h, areaH - 4);
|
|
|
|
unsigned long bg = isSel
|
|
? pt.spec.bgInputFocus
|
|
: (cg.incoming ? pt.spec.bgBubbleIn
|
|
: pt.spec.bgBubbleOut);
|
|
XSetForeground(dpy, gc, bg);
|
|
XFillRectangle(dpy, pix, gc, cardX, curY, cardW, cardH);
|
|
|
|
unsigned long accent =
|
|
cg.incoming ? pt.spec.success : pt.spec.info;
|
|
XSetForeground(dpy, gc, isSel ? pt.spec.accent : accent);
|
|
if (cg.incoming) {
|
|
XFillRectangle(dpy, pix, gc, cardX, curY, 3, cardH);
|
|
} else {
|
|
XFillRectangle(dpy, pix, gc, cardX + cardW - 3, curY, 3, cardH);
|
|
}
|
|
|
|
drawPango(layoutSmall, cg.incoming ? &colGreen : &colBlue,
|
|
cardX + 8, curY + 2, 14, cg.header, cardW - 14);
|
|
|
|
pango_layout_set_width(layoutSmall, (cardW - 14) * PANGO_SCALE);
|
|
pango_layout_set_wrap(layoutSmall, PANGO_WRAP_WORD_CHAR);
|
|
pango_layout_set_ellipsize(layoutSmall, PANGO_ELLIPSIZE_NONE);
|
|
pango_layout_set_text(layoutSmall, cg.body.c_str(),
|
|
static_cast<int>(cg.body.size()));
|
|
pango_xft_render_layout(xftDraw, &colWhite, layoutSmall,
|
|
(cardX + 8) * PANGO_SCALE,
|
|
(curY + 18) * PANGO_SCALE);
|
|
|
|
curY += cardH + 4;
|
|
}
|
|
}
|
|
|
|
drawSoftkeyBar("Options", "Reply", "Back");
|
|
|
|
} else if (mode == ViewMode::Compose) {
|
|
XSetForeground(dpy, gc, pt.spec.bgHeader);
|
|
XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 32);
|
|
XSetForeground(dpy, gc, pt.spec.border);
|
|
XDrawLine(dpy, pix, gc, 0, 31, kWinWidth, 31);
|
|
|
|
drawPango(layoutIcon, &colBlue, 6, 0, 30, kIconSend);
|
|
drawPango(layoutMain, &colWhite, 26, 0, 30, "Compose", 100);
|
|
|
|
const char* modeBadge = "[Abc]";
|
|
if (composeField == 0) {
|
|
modeBadge = "[123]";
|
|
} else if (caseMode == TextCaseMode::Upper) {
|
|
modeBadge = "[ABC]";
|
|
} else if (caseMode == TextCaseMode::Lower) {
|
|
modeBadge = "[abc]";
|
|
} else if (caseMode == TextCaseMode::Num) {
|
|
modeBadge = "[123]";
|
|
}
|
|
int segs = std::max(1, static_cast<int>(composeText.size() + 159) / 160);
|
|
std::string rightHdr = std::string(modeBadge) + " " +
|
|
std::to_string(composeText.size()) + "/" +
|
|
std::to_string(segs * 160);
|
|
pango_layout_set_width(layoutSmall, -1);
|
|
pango_layout_set_text(layoutSmall, rightHdr.c_str(),
|
|
static_cast<int>(rightHdr.size()));
|
|
int rw = 0, rhh = 0;
|
|
pango_layout_get_pixel_size(layoutSmall, &rw, &rhh);
|
|
drawPango(layoutSmall, &colYellow, kWinWidth - rw - 6, 0, 30, rightHdr);
|
|
|
|
// Field 0: Recipient
|
|
bool toSel = (composeField == 0);
|
|
XSetForeground(dpy, gc,
|
|
toSel ? pt.spec.bgInputFocus : pt.spec.bgInput);
|
|
XFillRectangle(dpy, pix, gc, 6, 36, kWinWidth - 12, 32);
|
|
XSetForeground(dpy, gc, toSel ? pt.spec.accentAlt : pt.spec.border);
|
|
XDrawRectangle(dpy, pix, gc, 6, 36, kWinWidth - 13, 31);
|
|
|
|
std::string cname = book.resolveName(composeRecipient);
|
|
std::string toDisp = "To: " + composeRecipient;
|
|
if (toSel) toDisp += "|";
|
|
if (!cname.empty()) {
|
|
toDisp += " (" + cname + ")";
|
|
}
|
|
drawPango(layoutMain, toSel ? &colWhite : &colLight, 10, 36, 32, toDisp,
|
|
kWinWidth - 20);
|
|
|
|
// Field 1: Message Body
|
|
bool msgSel = (composeField == 1);
|
|
XSetForeground(dpy, gc,
|
|
msgSel ? pt.spec.bgInputFocus : pt.spec.bgInput);
|
|
XFillRectangle(dpy, pix, gc, 6, 72, kWinWidth - 12, 156);
|
|
XSetForeground(dpy, gc, msgSel ? pt.spec.accentAlt : pt.spec.border);
|
|
XDrawRectangle(dpy, pix, gc, 6, 72, kWinWidth - 13, 155);
|
|
|
|
std::string bodyDisp = composeText;
|
|
if (msgSel) bodyDisp += "|";
|
|
if (bodyDisp.empty()) {
|
|
drawPango(layoutSmall, &colMuted, 12, 78, 20,
|
|
"Type message (# Input mode)...", kWinWidth - 24);
|
|
} else {
|
|
pango_layout_set_width(layoutMain, (kWinWidth - 24) * PANGO_SCALE);
|
|
pango_layout_set_wrap(layoutMain, PANGO_WRAP_WORD_CHAR);
|
|
pango_layout_set_ellipsize(layoutMain, PANGO_ELLIPSIZE_NONE);
|
|
pango_layout_set_text(layoutMain, bodyDisp.c_str(),
|
|
static_cast<int>(bodyDisp.size()));
|
|
int tw = 0, th = 0;
|
|
pango_layout_get_pixel_size(layoutMain, &tw, &th);
|
|
int textY = 76;
|
|
if (th > 146) {
|
|
textY = 76 - (th - 146);
|
|
}
|
|
XRectangle clipRect {6, 73, static_cast<unsigned short>(kWinWidth - 12),
|
|
154};
|
|
XftDrawSetClipRectangles(xftDraw, 0, 0, &clipRect, 1);
|
|
pango_xft_render_layout(xftDraw, &colWhite, layoutMain,
|
|
12 * PANGO_SCALE, textY * PANGO_SCALE);
|
|
XftDrawSetClip(xftDraw, nullptr);
|
|
}
|
|
|
|
// Field 2 & 3: Send & Save Draft buttons
|
|
bool sendSel = (composeField == 2);
|
|
bool draftSel = (composeField == 3);
|
|
XSetForeground(dpy, gc, sendSel ? pt.spec.selBg : pt.spec.bgButton);
|
|
XFillRectangle(dpy, pix, gc, 6, 234, 118, 30);
|
|
XSetForeground(dpy, gc, sendSel ? pt.spec.accentAlt : pt.spec.border);
|
|
XDrawRectangle(dpy, pix, gc, 6, 234, 117, 29);
|
|
drawPango(layoutMain, &colGreen, 18, 234, 30, "Send SMS", 100);
|
|
|
|
XSetForeground(dpy, gc, draftSel ? pt.spec.selBg : pt.spec.bgButton);
|
|
XFillRectangle(dpy, pix, gc, 130, 234, 104, 30);
|
|
XSetForeground(dpy, gc, draftSel ? pt.spec.accentAlt : pt.spec.border);
|
|
XDrawRectangle(dpy, pix, gc, 130, 234, 103, 29);
|
|
drawPango(layoutMain, &colYellow, 146, 234, 30, "Draft", 84);
|
|
|
|
if (composeField == 0) {
|
|
drawSoftkeyBar("Contacts",
|
|
composeRecipient.empty() ? "Contacts" : "Next",
|
|
composeRecipient.empty() ? "Cancel" : "Clear");
|
|
} else if (composeField == 1) {
|
|
drawSoftkeyBar("Send", "Send",
|
|
composeText.empty() ? "Cancel" : "Del");
|
|
} else if (composeField == 2) {
|
|
drawSoftkeyBar("Send", "Send", "Cancel");
|
|
} else {
|
|
drawSoftkeyBar("Send", "Draft", "Cancel");
|
|
}
|
|
|
|
} else if (mode == ViewMode::PickContact) {
|
|
XSetForeground(dpy, gc, pt.spec.bgHeader);
|
|
XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 34);
|
|
drawPango(layoutIcon, &colBlue, 8, 0, 34, kIconBook);
|
|
drawPango(layoutMain, &colWhite, 30, 0, 34, "Select Recipient", 200);
|
|
|
|
const int listY = 36;
|
|
const int rowH = 38;
|
|
const int visibleRows = 6;
|
|
if (pickerContacts.empty()) {
|
|
drawPango(layoutMain, &colMuted, 16, 110, 24, "No saved contacts", 208);
|
|
} else {
|
|
int scroll = 0;
|
|
if (selectedPicker >= visibleRows) {
|
|
scroll = selectedPicker - visibleRows + 1;
|
|
}
|
|
for (int i = 0; i < visibleRows; ++i) {
|
|
int idx = scroll + i;
|
|
if (idx >= static_cast<int>(pickerContacts.size())) break;
|
|
const auto& c = pickerContacts[idx];
|
|
int ry = listY + i * rowH;
|
|
bool isSel = (idx == selectedPicker);
|
|
XSetForeground(dpy, gc, isSel ? pt.spec.selBg : pt.spec.bgMain);
|
|
XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH - 1);
|
|
drawPango(layoutIcon, c.isRecent ? &colYellow : &colBlue, 6, ry + 1,
|
|
20, c.isRecent ? kIconClock : kIconUser);
|
|
drawPango(layoutMain, &colWhite, 28, ry + 1, 20, c.name, 204);
|
|
drawPango(layoutSmall, isSel ? &colLight : &colMuted, 28, ry + 20, 16,
|
|
c.number, 204);
|
|
}
|
|
}
|
|
drawSoftkeyBar("Select", "Select", "Back");
|
|
|
|
} else if (mode == ViewMode::Options) {
|
|
XSetForeground(dpy, gc, pt.spec.bgHeader);
|
|
XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 34);
|
|
drawPango(layoutIcon, &colBlue, 8, 0, 34, kIconList);
|
|
drawPango(layoutMain, &colWhite, 30, 0, 34, "Message Options", 200);
|
|
|
|
const int listY = 38;
|
|
const int rowH = 36;
|
|
for (size_t i = 0; i < optionsList.size(); ++i) {
|
|
int ry = listY + static_cast<int>(i) * rowH;
|
|
bool isSel = (static_cast<int>(i) == selectedOption);
|
|
XSetForeground(dpy, gc, isSel ? pt.spec.selBg : pt.spec.bgMain);
|
|
XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH - 1);
|
|
drawPango(layoutIcon, isSel ? &colYellow : &colBlue, 10, ry, rowH,
|
|
optionsList[i].icon);
|
|
drawPango(layoutMain, &colWhite, 34, ry, rowH, optionsList[i].label,
|
|
198);
|
|
}
|
|
drawSoftkeyBar("Select", "Select", "Back");
|
|
|
|
} else if (mode == ViewMode::ConfirmDelete) {
|
|
XSetForeground(dpy, gc, pt.spec.bgHeader);
|
|
XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 36);
|
|
drawPango(layoutIcon, &colRed, 8, 0, 36, kIconTrash);
|
|
drawPango(layoutMain, &colWhite, 30, 0, 36, "Delete Thread?", 200);
|
|
|
|
drawPango(layoutMain, &colWhite, 14, 70, 26, activeNumber, 212);
|
|
drawPango(layoutSmall, &colMuted, 14, 98, 22,
|
|
"All SMS history for this number", 212);
|
|
drawPango(layoutSmall, &colMuted, 14, 118, 22, "will be removed.", 212);
|
|
|
|
for (int i = 0; i < 2; ++i) {
|
|
int ry = 164 + i * 40;
|
|
bool isSel = (confirmSelected == i);
|
|
XSetForeground(
|
|
dpy, gc,
|
|
isSel ? (i == 1 ? pt.spec.dangerBg : pt.spec.selBg)
|
|
: pt.spec.bgButton);
|
|
XFillRectangle(dpy, pix, gc, 16, ry, kWinWidth - 32, 34);
|
|
drawPango(layoutMain, &colWhite, 28, ry, 34,
|
|
i == 0 ? "No, Keep Thread" : "Yes, Delete Thread", 184);
|
|
}
|
|
drawSoftkeyBar("Select", "Confirm", "Cancel");
|
|
}
|
|
|
|
int64_t tNow = nowMs();
|
|
{
|
|
std::lock_guard<std::mutex> lk(sendMutex);
|
|
if (!statusBanner.empty() && tNow < statusBannerUntilMs) {
|
|
XSetForeground(dpy, gc, pt.spec.bgToast);
|
|
XFillRectangle(dpy, pix, gc, 8, kWinHeight - 58, kWinWidth - 16, 28);
|
|
XSetForeground(dpy, gc, pt.spec.info);
|
|
XDrawRectangle(dpy, pix, gc, 8, kWinHeight - 58, kWinWidth - 17, 27);
|
|
drawPango(layoutSmall, &colWhite, 14, kWinHeight - 58, 28, statusBanner,
|
|
kWinWidth - 28);
|
|
}
|
|
}
|
|
|
|
XCopyArea(dpy, pix, win, gc, 0, 0, kWinWidth, kWinHeight, 0, 0);
|
|
XFlush(dpy);
|
|
};
|
|
|
|
auto showWindow = [&](const std::string& argStr) {
|
|
writeIntFile(activePidPath, static_cast<int>(::getpid()));
|
|
closeOtherPlugins("sms");
|
|
reloadAllData(true);
|
|
lastTapDigit = -1;
|
|
zeroHeld = false;
|
|
softkeyPending = 0;
|
|
|
|
if (argStr.rfind("send:", 0) == 0) {
|
|
openComposeView(argStr.substr(5));
|
|
} else if (argStr.rfind("thread:", 0) == 0) {
|
|
openThreadView(argStr.substr(7));
|
|
} else if (!argStr.empty()) {
|
|
openThreadView(argStr);
|
|
} else {
|
|
mode = ViewMode::Threads;
|
|
searchQuery.clear();
|
|
updateFilter();
|
|
selectedThread = 0;
|
|
}
|
|
|
|
if (!visible) {
|
|
visible = true;
|
|
XMapRaised(dpy, win);
|
|
} else {
|
|
XRaiseWindow(dpy, win);
|
|
}
|
|
grabPluginKeyboard(dpy, win);
|
|
openStartMs = nowMs();
|
|
startupIgnoreUntilMs = openStartMs + 180;
|
|
initialSoftkeysReleased = (querySoftkeysDown(dpy) == 0);
|
|
drawUI();
|
|
};
|
|
|
|
auto hideWindow = [&]() {
|
|
if (!visible) return;
|
|
if (mode == ViewMode::Compose && !composeRecipient.empty()) {
|
|
saveSmsDraft(normalizePhone(composeRecipient), composeText);
|
|
}
|
|
writeSoftkeySuppress(nowMs() + 450);
|
|
XUngrabKeyboard(dpy, CurrentTime);
|
|
XUnmapWindow(dpy, win);
|
|
XFlush(dpy);
|
|
visible = false;
|
|
::unlink(activePidPath.c_str());
|
|
};
|
|
|
|
auto commitMultiTapChar = [&](int digit) {
|
|
const char* chars = kMultiTapSms[digit];
|
|
int len = static_cast<int>(std::strlen(chars));
|
|
if (len <= 0) return;
|
|
|
|
int64_t t = nowMs();
|
|
if (caseMode == TextCaseMode::Num) {
|
|
composeText.push_back(static_cast<char>('0' + digit));
|
|
lastTapDigit = -1;
|
|
return;
|
|
}
|
|
|
|
bool sameKey = (lastTapDigit == digit && (t - lastTapMs) <= 850 &&
|
|
!composeText.empty());
|
|
if (sameKey) {
|
|
lastTapCycle = (lastTapCycle + 1) % len;
|
|
popUtf8Char(composeText);
|
|
} else {
|
|
lastTapDigit = digit;
|
|
lastTapCycle = 0;
|
|
}
|
|
lastTapMs = t;
|
|
|
|
char ch = chars[lastTapCycle];
|
|
if (std::isalpha(static_cast<unsigned char>(ch))) {
|
|
if (caseMode == TextCaseMode::Upper) {
|
|
ch = static_cast<char>(std::toupper(static_cast<unsigned char>(ch)));
|
|
} else if (caseMode == TextCaseMode::Title) {
|
|
bool startOfWord = composeText.empty() ||
|
|
composeText.back() == ' ' ||
|
|
composeText.back() == '\n' ||
|
|
composeText.back() == '.';
|
|
if (startOfWord) {
|
|
ch = static_cast<char>(std::toupper(static_cast<unsigned char>(ch)));
|
|
}
|
|
}
|
|
}
|
|
composeText.push_back(ch);
|
|
};
|
|
|
|
auto executeOption = [&](int optId) {
|
|
if (optId == 1) {
|
|
openComposeView("");
|
|
} else if (optId == 2 && !filteredThreads.empty()) {
|
|
openThreadView(filteredThreads[selectedThread].number);
|
|
} else if (optId == 3) {
|
|
std::string target = (prevMode == ViewMode::ThreadView)
|
|
? activeNumber
|
|
: (!filteredThreads.empty()
|
|
? filteredThreads[selectedThread].number
|
|
: "");
|
|
openComposeView(target);
|
|
} else if (optId == 4) {
|
|
std::string target = (prevMode == ViewMode::ThreadView)
|
|
? activeNumber
|
|
: (!filteredThreads.empty()
|
|
? filteredThreads[selectedThread].number
|
|
: "");
|
|
if (!target.empty()) {
|
|
hideWindow();
|
|
placePhoneCall(target);
|
|
}
|
|
} else if (optId == 5) {
|
|
std::string target = (prevMode == ViewMode::ThreadView)
|
|
? activeNumber
|
|
: (!filteredThreads.empty()
|
|
? filteredThreads[selectedThread].number
|
|
: "");
|
|
hideWindow();
|
|
if (!target.empty() && book.resolveName(target).empty()) {
|
|
sendPluginCommand("contacts", "OPEN new:" + target);
|
|
} else {
|
|
sendPluginCommand("contacts", "OPEN " + target);
|
|
}
|
|
} else if (optId == 6) {
|
|
activeNumber = (prevMode == ViewMode::ThreadView)
|
|
? activeNumber
|
|
: (!filteredThreads.empty()
|
|
? filteredThreads[selectedThread].number
|
|
: "");
|
|
if (!activeNumber.empty()) {
|
|
confirmSelected = 0;
|
|
mode = ViewMode::ConfirmDelete;
|
|
} else {
|
|
mode = prevMode;
|
|
}
|
|
} else if (optId == 7 && !threadMessages.empty()) {
|
|
std::string body = threadMessages[selectedMsg].text;
|
|
openComposeView("", body);
|
|
}
|
|
};
|
|
|
|
auto handleSoftkeyAction = [&](bool isLeft) {
|
|
if (mode == ViewMode::Threads) {
|
|
if (isLeft) {
|
|
prevMode = ViewMode::Threads;
|
|
buildOptions();
|
|
mode = ViewMode::Options;
|
|
} else {
|
|
if (!searchQuery.empty()) {
|
|
popUtf8Char(searchQuery);
|
|
updateFilter();
|
|
} else {
|
|
hideWindow();
|
|
return;
|
|
}
|
|
}
|
|
} else if (mode == ViewMode::ThreadView) {
|
|
if (isLeft) {
|
|
prevMode = ViewMode::ThreadView;
|
|
buildOptions();
|
|
mode = ViewMode::Options;
|
|
} else {
|
|
reloadAllData(true);
|
|
mode = ViewMode::Threads;
|
|
}
|
|
} else if (mode == ViewMode::Compose) {
|
|
if (isLeft) {
|
|
if (composeField == 0) {
|
|
pickerContacts = book.buildDialerList();
|
|
selectedPicker = 0;
|
|
mode = ViewMode::PickContact;
|
|
} else {
|
|
startAsyncSendSms(composeRecipient, composeText);
|
|
}
|
|
} else {
|
|
if (composeField == 0 && !composeRecipient.empty()) {
|
|
popUtf8Char(composeRecipient);
|
|
} else if (composeField == 1 && !composeText.empty()) {
|
|
popUtf8Char(composeText);
|
|
lastTapDigit = -1;
|
|
} else {
|
|
if (!composeRecipient.empty()) {
|
|
saveSmsDraft(normalizePhone(composeRecipient), composeText);
|
|
}
|
|
reloadAllData(true);
|
|
mode = (prevMode == ViewMode::ThreadView) ? ViewMode::ThreadView
|
|
: ViewMode::Threads;
|
|
}
|
|
}
|
|
} else if (mode == ViewMode::PickContact) {
|
|
if (isLeft) {
|
|
if (!pickerContacts.empty()) {
|
|
composeRecipient = pickerContacts[selectedPicker].number;
|
|
composeField = 1;
|
|
}
|
|
mode = ViewMode::Compose;
|
|
} else {
|
|
mode = ViewMode::Compose;
|
|
}
|
|
} else if (mode == ViewMode::Options) {
|
|
if (isLeft) {
|
|
if (!optionsList.empty()) {
|
|
executeOption(optionsList[selectedOption].id);
|
|
}
|
|
} else {
|
|
mode = prevMode;
|
|
}
|
|
} else if (mode == ViewMode::ConfirmDelete) {
|
|
if (isLeft) {
|
|
if (confirmSelected == 1) {
|
|
deleteSmsThread(activeNumber);
|
|
reloadAllData(true);
|
|
mode = ViewMode::Threads;
|
|
} else {
|
|
mode = prevMode;
|
|
}
|
|
} else {
|
|
mode = prevMode;
|
|
}
|
|
}
|
|
drawUI();
|
|
};
|
|
|
|
if (!isPreload) {
|
|
showWindow(initArg);
|
|
}
|
|
|
|
int xfd = ConnectionNumber(dpy);
|
|
while (gRunning) {
|
|
for (const auto& cmd : readFifoLines(fifoFd)) {
|
|
if (cmd == "NOP") {
|
|
continue;
|
|
} else if (cmd == "RELOAD_THEME") {
|
|
pt.reload();
|
|
if (visible) drawUI();
|
|
} else if (cmd == "CLOSE") {
|
|
hideWindow();
|
|
} else if (cmd == "OPEN") {
|
|
showWindow("");
|
|
} else if (cmd.rfind("OPEN ", 0) == 0) {
|
|
showWindow(trimStr(cmd.substr(5)));
|
|
}
|
|
}
|
|
|
|
while (XPending(dpy) > 0) {
|
|
XEvent ev;
|
|
XNextEvent(dpy, &ev);
|
|
if (!visible) continue;
|
|
|
|
if (ev.type == Expose && ev.xexpose.count == 0) {
|
|
drawUI();
|
|
} else if (ev.type == VisibilityNotify &&
|
|
ev.xvisibility.state != VisibilityUnobscured) {
|
|
if (isAnotherPluginActive("sms")) {
|
|
hideWindow();
|
|
} else {
|
|
XRaiseWindow(dpy, win);
|
|
grabPluginKeyboard(dpy, win);
|
|
drawUI();
|
|
}
|
|
} else if (ev.type == KeyRelease) {
|
|
unsigned int kc = ev.xkey.keycode;
|
|
KeySym sym = XLookupKeysym(&ev.xkey, 0);
|
|
if (kc == 19 || sym == XK_0 || sym == XK_KP_0) {
|
|
zeroHeld = false;
|
|
}
|
|
} else if (ev.type == KeyPress) {
|
|
int64_t tNow = nowMs();
|
|
unsigned int kc = ev.xkey.keycode;
|
|
KeySym sym = XLookupKeysym(&ev.xkey, 0);
|
|
|
|
bool isLeftSk =
|
|
(kc == 135 || sym == XK_Menu || sym == XF86XK_KbdLcdMenu1 ||
|
|
sym == _EVDEVK(0x246) || sym == XK_F1);
|
|
bool isRightSk =
|
|
(kc == 166 || sym == XF86XK_Back || sym == XF86XK_KbdLcdMenu2 ||
|
|
sym == _EVDEVK(0x247) || sym == XK_F2);
|
|
|
|
if (!initialSoftkeysReleased) {
|
|
if (querySoftkeysDown(dpy) == 0) {
|
|
initialSoftkeysReleased = true;
|
|
} else if (isLeftSk || isRightSk) {
|
|
continue;
|
|
}
|
|
}
|
|
if (tNow < startupIgnoreUntilMs && (isLeftSk || isRightSk)) {
|
|
continue;
|
|
}
|
|
|
|
if (isLeftSk || isRightSk) {
|
|
int bit = isLeftSk ? 1 : 2;
|
|
softkeyPending |= bit;
|
|
if (softkeyPending == 3 || querySoftkeysDown(dpy) == 3) {
|
|
softkeyPending = 0;
|
|
sendPluginCommand("xdrawer", "TOGGLE");
|
|
continue;
|
|
}
|
|
softkeyDeadlineMs = tNow + 35;
|
|
continue;
|
|
}
|
|
|
|
if (sym == XK_Escape || kc == 124 || sym == XF86XK_PowerOff) {
|
|
hideWindow();
|
|
continue;
|
|
}
|
|
|
|
bool isCallKey =
|
|
(kc == 177 || sym == XF86XK_PickupPhone || sym == XF86XK_Phone ||
|
|
sym == _EVDEVK(0x1bd) || sym == _EVDEVK(0x0a9));
|
|
if (isCallKey) {
|
|
if (mode == ViewMode::Compose) {
|
|
startAsyncSendSms(composeRecipient, composeText);
|
|
drawUI();
|
|
} else if (mode == ViewMode::ThreadView && !activeNumber.empty()) {
|
|
hideWindow();
|
|
placePhoneCall(activeNumber);
|
|
} else if (mode == ViewMode::Threads && !filteredThreads.empty()) {
|
|
std::string num = filteredThreads[selectedThread].number;
|
|
hideWindow();
|
|
placePhoneCall(num);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (sym == XK_BackSpace || sym == XK_Delete) {
|
|
handleSoftkeyAction(false);
|
|
continue;
|
|
}
|
|
|
|
if (mode == ViewMode::Threads) {
|
|
if (sym == XK_Up || sym == XK_KP_Up) {
|
|
if (!filteredThreads.empty()) {
|
|
selectedThread =
|
|
(selectedThread - 1 + static_cast<int>(filteredThreads.size())) %
|
|
static_cast<int>(filteredThreads.size());
|
|
drawUI();
|
|
}
|
|
} else if (sym == XK_Down || sym == XK_KP_Down) {
|
|
if (!filteredThreads.empty()) {
|
|
selectedThread =
|
|
(selectedThread + 1) % static_cast<int>(filteredThreads.size());
|
|
drawUI();
|
|
}
|
|
} else if (sym == XK_Return || sym == XK_KP_Enter) {
|
|
if (filteredThreads.empty()) {
|
|
openComposeView("");
|
|
} else {
|
|
openThreadView(filteredThreads[selectedThread].number);
|
|
}
|
|
drawUI();
|
|
} else {
|
|
char d = mapDialKey(sym, kc, ev.xkey.state);
|
|
if (d >= '0' && d <= '9') {
|
|
searchQuery.push_back(d);
|
|
updateFilter();
|
|
drawUI();
|
|
} else if (d == '*') {
|
|
openComposeView("");
|
|
drawUI();
|
|
}
|
|
}
|
|
} else if (mode == ViewMode::ThreadView) {
|
|
if (sym == XK_Up || sym == XK_KP_Up) {
|
|
if (selectedMsg > 0) {
|
|
--selectedMsg;
|
|
drawUI();
|
|
}
|
|
} else if (sym == XK_Down || sym == XK_KP_Down) {
|
|
if (selectedMsg + 1 < static_cast<int>(threadMessages.size())) {
|
|
++selectedMsg;
|
|
drawUI();
|
|
}
|
|
} else if (sym == XK_Return || sym == XK_KP_Enter) {
|
|
openComposeView(activeNumber);
|
|
drawUI();
|
|
}
|
|
} else if (mode == ViewMode::Compose) {
|
|
if (sym == XK_Up || sym == XK_KP_Up) {
|
|
lastTapDigit = -1;
|
|
composeField = std::max(0, composeField - 1);
|
|
drawUI();
|
|
} else if (sym == XK_Down || sym == XK_KP_Down) {
|
|
lastTapDigit = -1;
|
|
composeField = std::min(3, composeField + 1);
|
|
drawUI();
|
|
} else if (sym == XK_Left || sym == XK_KP_Left) {
|
|
if (composeField == 3) {
|
|
composeField = 2;
|
|
drawUI();
|
|
} else {
|
|
lastTapDigit = -1;
|
|
}
|
|
} else if (sym == XK_Right || sym == XK_KP_Right) {
|
|
if (composeField == 2) {
|
|
composeField = 3;
|
|
drawUI();
|
|
} else if (composeField == 0 && composeRecipient.empty()) {
|
|
composeRecipient = "+";
|
|
drawUI();
|
|
} else {
|
|
lastTapDigit = -1;
|
|
}
|
|
} else if (sym == XK_Return || sym == XK_KP_Enter) {
|
|
if (composeField == 0) {
|
|
if (composeRecipient.empty()) {
|
|
pickerContacts = book.buildDialerList();
|
|
selectedPicker = 0;
|
|
mode = ViewMode::PickContact;
|
|
} else {
|
|
composeField = 1;
|
|
}
|
|
} else if (composeField == 1 || composeField == 2) {
|
|
startAsyncSendSms(composeRecipient, composeText);
|
|
} else if (composeField == 3) {
|
|
saveSmsDraft(normalizePhone(composeRecipient), composeText);
|
|
statusBanner = "Draft saved";
|
|
statusBannerUntilMs = nowMs() + 1800;
|
|
reloadAllData(true);
|
|
mode = (prevMode == ViewMode::ThreadView) ? ViewMode::ThreadView
|
|
: ViewMode::Threads;
|
|
}
|
|
drawUI();
|
|
} else {
|
|
char d = mapDialKey(sym, kc, ev.xkey.state);
|
|
if (composeField == 0) {
|
|
if (d == '0') {
|
|
zeroHeld = true;
|
|
zeroPressMs = tNow;
|
|
composeRecipient.push_back('0');
|
|
drawUI();
|
|
} else if ((d >= '1' && d <= '9') || d == '+') {
|
|
composeRecipient.push_back(d);
|
|
drawUI();
|
|
}
|
|
} else if (composeField == 1) {
|
|
if (d == '#') {
|
|
lastTapDigit = -1;
|
|
caseMode = static_cast<TextCaseMode>(
|
|
(static_cast<int>(caseMode) + 1) % 4);
|
|
drawUI();
|
|
} else if (d == '*') {
|
|
commitMultiTapChar(1);
|
|
drawUI();
|
|
} else if (d >= '0' && d <= '9') {
|
|
commitMultiTapChar(d - '0');
|
|
drawUI();
|
|
} else if (sym == XK_space) {
|
|
composeText.push_back(' ');
|
|
lastTapDigit = -1;
|
|
drawUI();
|
|
}
|
|
}
|
|
}
|
|
} else if (mode == ViewMode::PickContact) {
|
|
if (sym == XK_Up || sym == XK_KP_Up) {
|
|
if (!pickerContacts.empty()) {
|
|
selectedPicker =
|
|
(selectedPicker - 1 + static_cast<int>(pickerContacts.size())) %
|
|
static_cast<int>(pickerContacts.size());
|
|
drawUI();
|
|
}
|
|
} else if (sym == XK_Down || sym == XK_KP_Down) {
|
|
if (!pickerContacts.empty()) {
|
|
selectedPicker =
|
|
(selectedPicker + 1) % static_cast<int>(pickerContacts.size());
|
|
drawUI();
|
|
}
|
|
} else if (sym == XK_Return || sym == XK_KP_Enter) {
|
|
handleSoftkeyAction(true);
|
|
}
|
|
} else if (mode == ViewMode::Options) {
|
|
if (sym == XK_Up || sym == XK_KP_Up) {
|
|
if (!optionsList.empty()) {
|
|
selectedOption =
|
|
(selectedOption - 1 + static_cast<int>(optionsList.size())) %
|
|
static_cast<int>(optionsList.size());
|
|
drawUI();
|
|
}
|
|
} else if (sym == XK_Down || sym == XK_KP_Down) {
|
|
if (!optionsList.empty()) {
|
|
selectedOption =
|
|
(selectedOption + 1) % static_cast<int>(optionsList.size());
|
|
drawUI();
|
|
}
|
|
} else if (sym == XK_Return || sym == XK_KP_Enter) {
|
|
handleSoftkeyAction(true);
|
|
}
|
|
} else if (mode == ViewMode::ConfirmDelete) {
|
|
if (sym == XK_Up || sym == XK_Down || sym == XK_KP_Up ||
|
|
sym == XK_KP_Down) {
|
|
confirmSelected = 1 - confirmSelected;
|
|
drawUI();
|
|
} else if (sym == XK_Return || sym == XK_KP_Enter) {
|
|
handleSoftkeyAction(true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
int64_t tNow = nowMs();
|
|
if (visible && softkeyPending != 0 && tNow >= softkeyDeadlineMs) {
|
|
if (querySoftkeysDown(dpy) == 3) {
|
|
softkeyPending = 0;
|
|
sendPluginCommand("xdrawer", "TOGGLE");
|
|
} else {
|
|
bool isLeft = (softkeyPending == 1);
|
|
softkeyPending = 0;
|
|
handleSoftkeyAction(isLeft);
|
|
}
|
|
}
|
|
|
|
if (visible && zeroHeld && mode == ViewMode::Compose && composeField == 0 &&
|
|
(tNow - zeroPressMs) >= 420) {
|
|
if (isKeycodeDown(dpy, 19)) {
|
|
if (!composeRecipient.empty() && composeRecipient.back() == '0') {
|
|
composeRecipient.back() = '+';
|
|
drawUI();
|
|
}
|
|
}
|
|
zeroHeld = false;
|
|
}
|
|
|
|
int timeoutMs = -1;
|
|
if (visible) {
|
|
timeoutMs = 250;
|
|
if (softkeyPending != 0) {
|
|
timeoutMs = std::min(timeoutMs,
|
|
std::max(1, static_cast<int>(softkeyDeadlineMs - tNow)));
|
|
}
|
|
if (zeroHeld) {
|
|
timeoutMs = std::min(
|
|
timeoutMs,
|
|
std::max(1, static_cast<int>((zeroPressMs + 420) - tNow)));
|
|
}
|
|
}
|
|
|
|
struct pollfd pfds[3];
|
|
int nfds = 0;
|
|
pfds[nfds++] = {xfd, POLLIN, 0};
|
|
int fifoIdx = -1;
|
|
if (fifoFd >= 0) {
|
|
fifoIdx = nfds;
|
|
pfds[nfds++] = {fifoFd, POLLIN, 0};
|
|
}
|
|
int pipeIdx = -1;
|
|
if (notifyPipe[0] >= 0) {
|
|
pipeIdx = nfds;
|
|
pfds[nfds++] = {notifyPipe[0], POLLIN, 0};
|
|
}
|
|
|
|
int pr = ::poll(pfds, nfds, timeoutMs);
|
|
if (pr > 0) {
|
|
if (fifoIdx >= 0 && (pfds[fifoIdx].revents & POLLIN)) {
|
|
for (const auto& cmd : readFifoLines(fifoFd)) {
|
|
if (cmd == "RELOAD_THEME") {
|
|
pt.reload();
|
|
if (visible) drawUI();
|
|
} else if (cmd == "CLOSE") {
|
|
hideWindow();
|
|
} else if (cmd == "OPEN") {
|
|
showWindow("");
|
|
} else if (cmd.rfind("OPEN ", 0) == 0) {
|
|
showWindow(trimStr(cmd.substr(5)));
|
|
}
|
|
}
|
|
}
|
|
if (pipeIdx >= 0 && (pfds[pipeIdx].revents & POLLIN)) {
|
|
char drain[32];
|
|
while (::read(notifyPipe[0], drain, sizeof(drain)) > 0) {
|
|
}
|
|
std::string sentTo = normalizePhone(composeRecipient);
|
|
reloadAllData(true);
|
|
if (!sentTo.empty()) {
|
|
openThreadView(sentTo);
|
|
}
|
|
drawUI();
|
|
}
|
|
} else if (pr == 0 && visible) {
|
|
reloadAllData(false);
|
|
}
|
|
}
|
|
|
|
hideWindow();
|
|
if (fifoFd >= 0) ::close(fifoFd);
|
|
if (notifyPipe[0] >= 0) ::close(notifyPipe[0]);
|
|
if (notifyPipe[1] >= 0) ::close(notifyPipe[1]);
|
|
::unlink(daemonPidPath.c_str());
|
|
return 0;
|
|
}
|
|
|
|
int sms_plugin_init(const DwmHostApi* host) {
|
|
if (host && host->spawn_plugin) {
|
|
host->spawn_plugin("sms", "--preload");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
extern "C" __attribute__((visibility("default"))) const DwmPlugin dwm_plugin_entry = {
|
|
DWM_PLUGIN_API_VERSION,
|
|
"sms",
|
|
"Keypad-driven SMS conversation manager and editor integrated with Sxmo",
|
|
sms_plugin_init,
|
|
nullptr,
|
|
nullptr,
|
|
nullptr,
|
|
nullptr,
|
|
runSmsApp
|
|
};
|