EU4NRBVMDSHLWNJYAI76S77TRQ633XHLXK3XASUUIZGTQZCFRIIAC
'("1d609285d297920b97b0d230360a8d7c3f930edcf1abe96a1d82356f9a5e228d" "abe5ee8858cd1fbe36304a8c3b2315d3e0a4ef7c8588fcc45d1c23eafb725bb6" "d600c677f1777c1e4bfb066529b5b73c0179d0499dd4ffa3f599a0fb0cfbd501" "30289fa8d502f71a392f40a0941a83842152a68c54ad69e0638ef52f04777a4c" "c7f10959cb1bc7a36ee355c765a1768d48929ec55dde137da51077ac7f899521" "773e0bfa5450c75d613cbf29734cdc876c3d59dbf85b93cff3015a8687dea158" "bf798e9e8ff00d4bf2512597f36e5a135ce48e477ce88a0764cfb5d8104e8163" "1436d643b98844555d56c59c74004eb158dc85fc55d2e7205f8d9b8c860e177f" "1e9001d2f6ffb095eafd9514b4d5974b720b275143fbc89ea046495a99c940b0" "f11e219c9d043cbd5f4b2e01713c2c24a948a98bed48828dc670bd64ae771aa1" "8f97d5ec8a774485296e366fdde6ff5589cf9e319a584b845b6f7fa788c9fa9a" "2642a1b7f53b9bb34c7f1e032d2098c852811ec2881eec2dc8cc07be004e45a0" "5111a41453244802afd93eed1a434e612a8afbdf19c52384dffab129258bab6e" "0329c772ed96053a73b9ddddf96c1183e23c267955bbdf78e7933057ce9da04b" "36ca8f60565af20ef4f30783aa16a26d96c02df7b4e54e9900a5138fb33808da" "23b564cfb74d784c73167d7de1b9a067bcca00719f81e46d09ee71a12ef7ee82" "f7ef6451d988d6e2fc86deea398eee02b3371703d88f265d31a011bd240dcf99" "3d4df186126c347e002c8366d32016948068d2e9198c496093a96775cc3b3eaa" "c560237b7505f67a271def31c706151afd7aa6eba9f69af77ec05bde5408dbcd" "24cb0b5666e1e17fb6a378c413682f57fe176775eda015eb0a98d65fbb64b127" "b0eb9e6e6dcb29c6ec4bcd72605188f482d52ee019cb0ccc73b5404be4f3a6e7" "08a89acffece58825e75479333109e01438650d27661b29212e6560070b156cf" "c9ddf33b383e74dac7690255dd2c3dfa1961a8e8a1d20e401c6572febef61045" "a22f40b63f9bc0a69ebc8ba4fbc6b452a4e3f84b80590ba0a92b4ff599e53ad0" "585942bb24cab2d4b2f74977ac3ba6ddbd888e3776b9d2f993c5704aa8bb4739" "fa2af0c40576f3bde32290d7f4e7aa865eb6bf7ebe31eb9e37c32aa6f4ae8d10" "85d1dbf2fc0e5d30f236712b831fb24faf6052f3114964fdeadede8e1b329832" "bffb799032a7404b33e431e6a1c46dc0ca62f54fdd20744a35a57c3f78586646" "779df25c6240f183dd378811465566082eacc3c86ed260835382cb5c1dc54de3" "eee70baee3a2d093a98fa344480d2330eb3a17f8dfa883cc41d8008cd46fa669" "b70474b8a631f73868f01fdb047f102dd36325ecbf6789962e613e9195e165d7" "ccdd152be99a9250f5c24e09860911ff4b1519a8c4c73e4d67364514a3359f71" "ba913d12adb68e9dadf1f43e6afa8e46c4822bb96a289d5bf1204344064f041e" "146614f131e5029df850a268aff1f0e3ae16734a119904eab133755b574da37d" "c8935cb969e24ff30a6ee1da011eadc156b9f128a3b0a39cc664628838a644ad" "767a6105de48707dd6a593b62c05a7bf4ee9930c86e3225410110d95906772d6" "39546362fed4d5201b2b386dc21f21439497c9eec5fee323d953b3e230e4083e" "2f4f50d98073c01038b518066840638455657dc91dd1a225286d573926f36914" "80ae3a89f1eca6fb94a525004f66b544e347c6f756aaafb728c7cdaef85ea1f5" "7b1a2c8ec90a30cc5d039db0e52f192a291f4c3655103cf669721f94028eac27" "bcd0237b2a5b7897e482458cc62c4f3fa3d9d7f9a9667338e67d4c7a8e009819" "8e79884e89740cf6b7e0210f52e4ac995dc1f1a9a17151bfcfb4d660757a011b" "454c1c9ce70f7d807c51c890910365fd3c64a9e63f596511e9ff57dd97bbeea8" "387b487737860e18cbb92d83a42616a67c1edfd0664d521940e7fbf049c315ae" "6231254e74298a1cf8a5fee7ca64352943de4b495e615c449e9bb27e2ccae709" "3577ee091e1d318c49889574a31175970472f6f182a9789f1a3e9e4513641d86" "cbd85ab34afb47003fa7f814a462c24affb1de81ebf172b78cb4e65186ba59d2" "8f5b54bf6a36fe1c138219960dd324aad8ab1f62f543bed73ef5ad60956e36ae" "9d8ad1c413fccc14d992f6bed0ead11f1798a05ee8913daaa24a24604c212b61" "e246ff6951678c835ab95aedb99a48fdd665656eadf8b8bc0a4f60e17fbc6b7e" "e57eec7e0399272aaca7985a5cc94f3a2675db4cd2dbd79a99c72786e489e43c" "d0fd069415ef23ccc21ccb0e54d93bdbb996a6cce48ffce7f810826bb243502c" "ffba0482d3548c9494e84c1324d527f73ea4e43fff8dfd0e48faa8fc6d5c2bc7" "9e816bc85b64963710efdbc57db8545b53f7f359b0736e0b36fbc9efe07f12f4" "6ac7c0f959f0d7853915012e78ff70150bfbe2a69a1b703c3ac4184f9ae3ae02" "845103fcb9b091b0958171653a4413ccfad35552bc39697d448941bcbe5a660d" "bc836bf29eab22d7e5b4c142d201bcce351806b7c1f94955ccafab8ce5b20208" "7f6d4aebcc44c264a64e714c3d9d1e903284305fd7e319e7cb73345a9994f5ef" "10a31b6c251640d04b2fa74bd2c05aaaee915cbca6501bcc82820cdc177f5a93" "6e933f1668e124ec17fc7b6547f65ba760e06efb568a6c8091c600c67827e592" "02199888a97767d7779269a39ba2e641d77661b31b3b8dd494b1a7250d1c8dc1" "2a49f95b19d5926d940db11057077df2d01faaf33e66c5a8d4ee8043a904695f" "bdb4509c123230a059d89fc837c40defdecee8279c741b7f060196b343b2d18d" "ed17fef69db375ae1ced71fdc12e543448827aac5eb7166d2fd05f4c95a7be71" "6515fcc302292f29a94f6ac0c5795c57a396127d5ea31f37fc5f9f0308bbe19f" "5a45c8bf60607dfa077b3e23edfb8df0f37c4759356682adf7ab762ba6b10600" "6c5a5c47749e7992b4da3011595f5470f33e19f29b10564cd4f62faebbe36b91" "8a379e7ac3a57e64de672dd744d4730b3bdb88ae328e8106f95cd81cbd44e0b6" "2035a16494e06636134de6d572ec47c30e26c3447eafeb6d3a9e8aee73732396" "ed0b4fc082715fc1d6a547650752cd8ec76c400ef72eb159543db1770a27caa7" "42b9d85321f5a152a6aef0cc8173e701f572175d6711361955ecfb4943fe93af" "021720af46e6e78e2be7875b2b5b05344f4e21fad70d17af7acfd6922386b61e" "9fcac3986e3550baac55dc6175195a4c7537e8aa082043dcbe3f93f548a3a1e0" "242527ce24b140d304381952aa7a081179a9848d734446d913ca8ef0af3cef21" "8cb818e0658f6cc59928a8f2b2917adc36d882267bf816994e00c5b8fcbf6933" "3fa81193ab414a4d54cde427c2662337c2cab5dd4eb17ffff0d90bca97581eb6" "44247f2a14c661d96d2bff302f1dbf37ebe7616935e4682102b68c0b6cc80095" "73bff6f2ef60f8a1238a9ca666235d258e3acdeeed85d092ca532788dd7a33c4" "05eeb814f74b2fd4f2e6e37b4d604eb9b1daaaedfa5e692f1d485250c6b553eb" "0055e55e6a357a941027139152a67f93376616d3501055d06852f10fdc16bac0" "1f3113447a652b8436a9938bbac71ecaf022cc73ecd0d76182eb9713aa781f17" "b755c709cb46ba4599ab3a3c189a056e9736cbbb39ed9ecfc1ab1aa3d6f79021" "75c5c39809c52d48cb9dcbf1694bf2d27d5f6fd053777c194e0b69d8e49031c0" "54e08527b4f4b127ebf7359acbbbecfab55152da01716c4809682eb71937fd33" "81db42d019a738d388596533bd1b5d66aef3663842172f3696733c0aab05a150" "ef1e992ef341e86397b39ee6b41c1368e1b33d45b0848feac6a8e8d5753daa67" "ad109c1ad8115573f40e22ac2b996693b5d48052fa37b5919f70ea37c62a965e" "b143ad150e25f9b6d09ae313718f85cc785c89445f3369f74bd0d836d5881c56" "dc9a8d70c4f94a28aafc7833f8d05667601968e6c9bf998791c39fcb3e4679c9" "125fd2180e880802ae98b85f282b17f0aa8fa6cb9fc4f33d7fb19a38c40acef0" "4d80487632a0a5a72737a7fc690f1f30266668211b17ba836602a8da890c2118" "9a155066ec746201156bb39f7518c1828a73d67742e11271e4f24b7b178c4710" "4bfced46dcfc40c45b076a1758ca106a947b1b6a6ff79a3281f3accacfb3243c" "31992d4488dba5b28ddb0c16914bf5726dc41588c2b1c1a2fd16516ea92c1d8e" "0e33022384e4db1374827f51e3d9e9a2d56282c2e3568c22f1c12ad80e20cf0c" "83db918b06f0b1df1153f21c0d47250556c7ffb5b5e6906d21749f41737babb7" "47744f6c8133824bdd104acc4280dbed4b34b85faa05ac2600f716b0226fb3f6" "8b313e1793da427e90c034dbe74f3ad9092ac291846c0f855908c42a6bda1ff4" "43c1a8090ed19ab3c0b1490ce412f78f157d69a29828aa977dae941b994b4147" "bc4b650c41b16b98166b35da94b366c6a9e1e7883bbf4937c897fb7bd05aa619" "e9460a84d876da407d9e6accf9ceba453e2f86f8b86076f37c08ad155de8223c" "8ec2e01474ad56ee33bc0534bdbe7842eea74dccfb576e09f99ef89a705f5501" "d606ac41cdd7054841941455c0151c54f8bff7e4e050255dbd4ae4d60ab640c1" "4f2ede02b3324c2f788f4e0bad77f7ebc1874eff7971d2a2c9b9724a50fb3f65" "2b6bd2ebad907ee42b3ffefa4831f348e3652ea8245570cdda67f0034f07db93" "7f3ef7724515515443f961ef87fee655750512473b1f5bf890e2dc7e065f240c" "65d9573b64ec94844f95e6055fe7a82451215f551c45275ca5b78653d505bc42" "39dffaee0e575731c909bb3e4b411f1c4759c3d7510bf02aa5aef322a596dd57" "2cf7f9d1d8e4d735ba53facdc3c6f3271086b6906c4165b12e4fd8e3865469a6" default))
'("7c0830e1aea024fc0d4e76ba3db9f78cb37c09da2290f38af1a8fe87881007f7" "474513bacf33a439da7b9a5df1dd11a277929d8480752675fc7d5f3816d8fdef" "cfe4d36ed4cf00a541f7ba0deb38c94808c13a3e4c717f07bc3b9c866670e8d1" "2ff9ac386eac4dffd77a33e93b0c8236bb376c5a5df62e36d4bfa821d56e4e20" "1d609285d297920b97b0d230360a8d7c3f930edcf1abe96a1d82356f9a5e228d" "abe5ee8858cd1fbe36304a8c3b2315d3e0a4ef7c8588fcc45d1c23eafb725bb6" "d600c677f1777c1e4bfb066529b5b73c0179d0499dd4ffa3f599a0fb0cfbd501" "30289fa8d502f71a392f40a0941a83842152a68c54ad69e0638ef52f04777a4c" "c7f10959cb1bc7a36ee355c765a1768d48929ec55dde137da51077ac7f899521" "773e0bfa5450c75d613cbf29734cdc876c3d59dbf85b93cff3015a8687dea158" "bf798e9e8ff00d4bf2512597f36e5a135ce48e477ce88a0764cfb5d8104e8163" "1436d643b98844555d56c59c74004eb158dc85fc55d2e7205f8d9b8c860e177f" "1e9001d2f6ffb095eafd9514b4d5974b720b275143fbc89ea046495a99c940b0" "f11e219c9d043cbd5f4b2e01713c2c24a948a98bed48828dc670bd64ae771aa1" "8f97d5ec8a774485296e366fdde6ff5589cf9e319a584b845b6f7fa788c9fa9a" "2642a1b7f53b9bb34c7f1e032d2098c852811ec2881eec2dc8cc07be004e45a0" "5111a41453244802afd93eed1a434e612a8afbdf19c52384dffab129258bab6e" "0329c772ed96053a73b9ddddf96c1183e23c267955bbdf78e7933057ce9da04b" "36ca8f60565af20ef4f30783aa16a26d96c02df7b4e54e9900a5138fb33808da" "23b564cfb74d784c73167d7de1b9a067bcca00719f81e46d09ee71a12ef7ee82" "f7ef6451d988d6e2fc86deea398eee02b3371703d88f265d31a011bd240dcf99" "3d4df186126c347e002c8366d32016948068d2e9198c496093a96775cc3b3eaa" "c560237b7505f67a271def31c706151afd7aa6eba9f69af77ec05bde5408dbcd" "24cb0b5666e1e17fb6a378c413682f57fe176775eda015eb0a98d65fbb64b127" "b0eb9e6e6dcb29c6ec4bcd72605188f482d52ee019cb0ccc73b5404be4f3a6e7" "08a89acffece58825e75479333109e01438650d27661b29212e6560070b156cf" "c9ddf33b383e74dac7690255dd2c3dfa1961a8e8a1d20e401c6572febef61045" "a22f40b63f9bc0a69ebc8ba4fbc6b452a4e3f84b80590ba0a92b4ff599e53ad0" "585942bb24cab2d4b2f74977ac3ba6ddbd888e3776b9d2f993c5704aa8bb4739" "fa2af0c40576f3bde32290d7f4e7aa865eb6bf7ebe31eb9e37c32aa6f4ae8d10" "85d1dbf2fc0e5d30f236712b831fb24faf6052f3114964fdeadede8e1b329832" "bffb799032a7404b33e431e6a1c46dc0ca62f54fdd20744a35a57c3f78586646" "779df25c6240f183dd378811465566082eacc3c86ed260835382cb5c1dc54de3" "eee70baee3a2d093a98fa344480d2330eb3a17f8dfa883cc41d8008cd46fa669" "b70474b8a631f73868f01fdb047f102dd36325ecbf6789962e613e9195e165d7" "ccdd152be99a9250f5c24e09860911ff4b1519a8c4c73e4d67364514a3359f71" "ba913d12adb68e9dadf1f43e6afa8e46c4822bb96a289d5bf1204344064f041e" "146614f131e5029df850a268aff1f0e3ae16734a119904eab133755b574da37d" "c8935cb969e24ff30a6ee1da011eadc156b9f128a3b0a39cc664628838a644ad" "767a6105de48707dd6a593b62c05a7bf4ee9930c86e3225410110d95906772d6" "39546362fed4d5201b2b386dc21f21439497c9eec5fee323d953b3e230e4083e" "2f4f50d98073c01038b518066840638455657dc91dd1a225286d573926f36914" "80ae3a89f1eca6fb94a525004f66b544e347c6f756aaafb728c7cdaef85ea1f5" "7b1a2c8ec90a30cc5d039db0e52f192a291f4c3655103cf669721f94028eac27" "bcd0237b2a5b7897e482458cc62c4f3fa3d9d7f9a9667338e67d4c7a8e009819" "8e79884e89740cf6b7e0210f52e4ac995dc1f1a9a17151bfcfb4d660757a011b" "454c1c9ce70f7d807c51c890910365fd3c64a9e63f596511e9ff57dd97bbeea8" "387b487737860e18cbb92d83a42616a67c1edfd0664d521940e7fbf049c315ae" "6231254e74298a1cf8a5fee7ca64352943de4b495e615c449e9bb27e2ccae709" "3577ee091e1d318c49889574a31175970472f6f182a9789f1a3e9e4513641d86" "cbd85ab34afb47003fa7f814a462c24affb1de81ebf172b78cb4e65186ba59d2" "8f5b54bf6a36fe1c138219960dd324aad8ab1f62f543bed73ef5ad60956e36ae" "9d8ad1c413fccc14d992f6bed0ead11f1798a05ee8913daaa24a24604c212b61" "e246ff6951678c835ab95aedb99a48fdd665656eadf8b8bc0a4f60e17fbc6b7e" "e57eec7e0399272aaca7985a5cc94f3a2675db4cd2dbd79a99c72786e489e43c" "d0fd069415ef23ccc21ccb0e54d93bdbb996a6cce48ffce7f810826bb243502c" "ffba0482d3548c9494e84c1324d527f73ea4e43fff8dfd0e48faa8fc6d5c2bc7" "9e816bc85b64963710efdbc57db8545b53f7f359b0736e0b36fbc9efe07f12f4" "6ac7c0f959f0d7853915012e78ff70150bfbe2a69a1b703c3ac4184f9ae3ae02" "845103fcb9b091b0958171653a4413ccfad35552bc39697d448941bcbe5a660d" "bc836bf29eab22d7e5b4c142d201bcce351806b7c1f94955ccafab8ce5b20208" "7f6d4aebcc44c264a64e714c3d9d1e903284305fd7e319e7cb73345a9994f5ef" "10a31b6c251640d04b2fa74bd2c05aaaee915cbca6501bcc82820cdc177f5a93" "6e933f1668e124ec17fc7b6547f65ba760e06efb568a6c8091c600c67827e592" "02199888a97767d7779269a39ba2e641d77661b31b3b8dd494b1a7250d1c8dc1" "2a49f95b19d5926d940db11057077df2d01faaf33e66c5a8d4ee8043a904695f" "bdb4509c123230a059d89fc837c40defdecee8279c741b7f060196b343b2d18d" "ed17fef69db375ae1ced71fdc12e543448827aac5eb7166d2fd05f4c95a7be71" "6515fcc302292f29a94f6ac0c5795c57a396127d5ea31f37fc5f9f0308bbe19f" "5a45c8bf60607dfa077b3e23edfb8df0f37c4759356682adf7ab762ba6b10600" "6c5a5c47749e7992b4da3011595f5470f33e19f29b10564cd4f62faebbe36b91" "8a379e7ac3a57e64de672dd744d4730b3bdb88ae328e8106f95cd81cbd44e0b6" "2035a16494e06636134de6d572ec47c30e26c3447eafeb6d3a9e8aee73732396" "ed0b4fc082715fc1d6a547650752cd8ec76c400ef72eb159543db1770a27caa7" "42b9d85321f5a152a6aef0cc8173e701f572175d6711361955ecfb4943fe93af" "021720af46e6e78e2be7875b2b5b05344f4e21fad70d17af7acfd6922386b61e" "9fcac3986e3550baac55dc6175195a4c7537e8aa082043dcbe3f93f548a3a1e0" "242527ce24b140d304381952aa7a081179a9848d734446d913ca8ef0af3cef21" "8cb818e0658f6cc59928a8f2b2917adc36d882267bf816994e00c5b8fcbf6933" "3fa81193ab414a4d54cde427c2662337c2cab5dd4eb17ffff0d90bca97581eb6" "44247f2a14c661d96d2bff302f1dbf37ebe7616935e4682102b68c0b6cc80095" "73bff6f2ef60f8a1238a9ca666235d258e3acdeeed85d092ca532788dd7a33c4" "05eeb814f74b2fd4f2e6e37b4d604eb9b1daaaedfa5e692f1d485250c6b553eb" "0055e55e6a357a941027139152a67f93376616d3501055d06852f10fdc16bac0" "1f3113447a652b8436a9938bbac71ecaf022cc73ecd0d76182eb9713aa781f17" "b755c709cb46ba4599ab3a3c189a056e9736cbbb39ed9ecfc1ab1aa3d6f79021" "75c5c39809c52d48cb9dcbf1694bf2d27d5f6fd053777c194e0b69d8e49031c0" "54e08527b4f4b127ebf7359acbbbecfab55152da01716c4809682eb71937fd33" "81db42d019a738d388596533bd1b5d66aef3663842172f3696733c0aab05a150" "ef1e992ef341e86397b39ee6b41c1368e1b33d45b0848feac6a8e8d5753daa67" "ad109c1ad8115573f40e22ac2b996693b5d48052fa37b5919f70ea37c62a965e" "b143ad150e25f9b6d09ae313718f85cc785c89445f3369f74bd0d836d5881c56" "dc9a8d70c4f94a28aafc7833f8d05667601968e6c9bf998791c39fcb3e4679c9" "125fd2180e880802ae98b85f282b17f0aa8fa6cb9fc4f33d7fb19a38c40acef0" "4d80487632a0a5a72737a7fc690f1f30266668211b17ba836602a8da890c2118" "9a155066ec746201156bb39f7518c1828a73d67742e11271e4f24b7b178c4710" "4bfced46dcfc40c45b076a1758ca106a947b1b6a6ff79a3281f3accacfb3243c" "31992d4488dba5b28ddb0c16914bf5726dc41588c2b1c1a2fd16516ea92c1d8e" "0e33022384e4db1374827f51e3d9e9a2d56282c2e3568c22f1c12ad80e20cf0c" "83db918b06f0b1df1153f21c0d47250556c7ffb5b5e6906d21749f41737babb7" "47744f6c8133824bdd104acc4280dbed4b34b85faa05ac2600f716b0226fb3f6" "8b313e1793da427e90c034dbe74f3ad9092ac291846c0f855908c42a6bda1ff4" "43c1a8090ed19ab3c0b1490ce412f78f157d69a29828aa977dae941b994b4147" "bc4b650c41b16b98166b35da94b366c6a9e1e7883bbf4937c897fb7bd05aa619" "e9460a84d876da407d9e6accf9ceba453e2f86f8b86076f37c08ad155de8223c" "8ec2e01474ad56ee33bc0534bdbe7842eea74dccfb576e09f99ef89a705f5501" "d606ac41cdd7054841941455c0151c54f8bff7e4e050255dbd4ae4d60ab640c1" "4f2ede02b3324c2f788f4e0bad77f7ebc1874eff7971d2a2c9b9724a50fb3f65" "2b6bd2ebad907ee42b3ffefa4831f348e3652ea8245570cdda67f0034f07db93" "7f3ef7724515515443f961ef87fee655750512473b1f5bf890e2dc7e065f240c" "65d9573b64ec94844f95e6055fe7a82451215f551c45275ca5b78653d505bc42" "39dffaee0e575731c909bb3e4b411f1c4759c3d7510bf02aa5aef322a596dd57" "2cf7f9d1d8e4d735ba53facdc3c6f3271086b6906c4165b12e4fd8e3865469a6" default))
'(outshine meow pikchr-mode ement fennel-mode ox-gemini omtose-phellack-theme pyvenv-auto paredit warm-night-theme geiser-guile geiser geiser-chicken spacegray-theme soft-charcoal-theme slime abyss-theme ample-theme djvu elpher ox-rst ripgrep unicode-fonts no-spam uuid marginalia embark-consult embark orderless selectrum consult vertico dockerfile-mode x509-mode forth-mode browse-at-remote cider sesman clojure-mode eziam-theme scad-mode acme-theme sexy-monochrome-theme monotropic-theme constant-theme graphviz-dot-mode mood-line mood-one-theme modus-vivendi-theme modus-operandi-theme browse-url-dwim magit parchment-theme almost-mono-themes borland-blue-theme nordless-theme northcode-theme nord-theme nofrils-acme-theme multikeyfreq fireplace boon csv-mode ein markdown-mode puppet-mode dad-joke keyfreq avy-zap avy ggtags counsel-projectile projectile command-log-mode counsel vc-fossil visible-mark esup sudden-death jammer arjen-grey-theme atom-dark-theme clues-theme dark-krystal-theme darkburn-theme eink-theme foggy-night-theme green-screen-theme gruvbox-theme heroku-theme inverse-acme-theme iodine-theme labburn-theme leuven-theme evil-visual-mark-mode excorporate telephone-line xah-fly-keys which-key url-http-ntlm toml-mode plan9-theme ergoemacs-mode))
'(caml mastodon emacs-gc-stats outshine meow pikchr-mode ement fennel-mode ox-gemini omtose-phellack-theme pyvenv-auto paredit warm-night-theme geiser-guile geiser geiser-chicken spacegray-theme soft-charcoal-theme slime abyss-theme ample-theme djvu elpher ox-rst ripgrep unicode-fonts no-spam uuid marginalia embark-consult embark orderless selectrum consult vertico dockerfile-mode x509-mode forth-mode browse-at-remote cider sesman clojure-mode eziam-theme scad-mode acme-theme sexy-monochrome-theme monotropic-theme constant-theme graphviz-dot-mode mood-line mood-one-theme modus-vivendi-theme modus-operandi-theme browse-url-dwim magit parchment-theme almost-mono-themes borland-blue-theme nordless-theme northcode-theme nord-theme nofrils-acme-theme multikeyfreq fireplace boon csv-mode ein markdown-mode puppet-mode dad-joke keyfreq avy-zap avy ggtags counsel-projectile projectile command-log-mode counsel vc-fossil visible-mark esup sudden-death jammer arjen-grey-theme atom-dark-theme clues-theme dark-krystal-theme darkburn-theme eink-theme foggy-night-theme green-screen-theme gruvbox-theme heroku-theme inverse-acme-theme iodine-theme labburn-theme leuven-theme evil-visual-mark-mode excorporate telephone-line xah-fly-keys which-key url-http-ntlm toml-mode plan9-theme ergoemacs-mode))
(setq mastodon-instance-url "https://emacs.ch"
mastodon-active-user "jaredj")
;;; ts.el --- Timestamp and date/time library -*- lexical-binding: t; -*-
;; Copyright (C) 2018-2019 Adam Porter
;; Author: Adam Porter <adam@alphapapa.net>
;; URL: http://github.com/alphapapa/ts.el
;; Version: 0.3
;; Package-Requires: ((emacs "26.1") (dash "2.14.1") (s "1.12.0"))
;; Keywords: calendar, lisp
;; This file is not part of GNU Emacs.
;;; Commentary:
;; This package is designed to ease manipulation of dates, times, and
;; timestamps in Emacs.
;; A struct `ts' is defined, which represents a timestamp. All
;; manipulation is done internally using Unix timestamps. Accessors
;; are used to retrieve calendar values such as month, day, year from
;; a timestamp, and these values are cached in the struct once
;; accessed, to avoid repeatedly calling `format-time-string', which
;; is expensive. Function arguments are designed to work well with
;; the `thread-last' macro, to make sequential operations easy to
;; follow.
;; The current timestamp is retrieved with `ts-now'.
;; Timestamps are easily modified using `ts-adjust', `ts-apply',
;; `ts-incf', `ts-dec', etc.
;; Timestamps are parsed and formatted using `ts-parse',
;; `ts-parse-org', and `ts-format'.
;; Differences and durations are calculated with `ts-diff',
;; `ts-human-duration', and `ts-human-format-duration'. Comparisons
;; are done with `ts<', `ts<=', `ts=', `ts>', and `ts>='.
;;; License:
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <http://www.gnu.org/licenses/>.
;;; Code:
;;;; Requirements
(require 'cl-lib)
(require 'dash)
(require 's)
;;;; Variables
(defvar ts-default-format "%Y-%m-%d %H:%M:%S %z"
"Default format for `ts-format'.")
;;;; Structs
(cl-defmacro ts-defstruct (&rest args)
"Like `cl-defstruct', but with additional slot options from ARGS.
Additional slot options and values:
`:accessor-init': a sexp that initializes the slot in the
accessor if the slot is nil. The symbol `struct' will be bound
to the current struct. The accessor is defined after the struct
is fully defined, so it may refer to the struct
definition (e.g. by using the `cl-struct' `pcase' macro).
`:aliases': A list of symbols which will be aliased to the slot
accessor, prepended with the struct name (e.g. a struct `ts' with
slot `year' and alias `y' would create an alias `ts-y')."
(declare (indent defun))
;; FIXME: Compiler warnings about accessors defined multiple times. Not sure if we can fix this
;; except by ignoring warnings.
(let* ((struct-name (car args))
(struct-slots (cdr args))
(cl-defstruct-expansion (macroexpand `(cl-defstruct ,struct-name ,@struct-slots)))
accessor-forms alias-forms)
(cl-loop for slot in struct-slots
for pos from 1
when (listp slot)
do (-let* (((slot-name _slot-default . slot-options) slot)
((&keys :accessor-init :aliases) slot-options)
(accessor-name (intern (concat (symbol-name struct-name) "-" (symbol-name slot-name))))
(accessor-docstring (format "Access slot \"%s\" of `%s' struct STRUCT."
slot-name struct-name))
(struct-pred (intern (concat (symbol-name struct-name) "-p")))
;; Accessor form copied from macro expansion of `cl-defstruct'.
(accessor-form `(cl-defsubst ,accessor-name (struct)
,accessor-docstring
;; FIXME: side-effect-free is probably not true here, but what about error-free?
;; (declare (side-effect-free error-free))
(or (,struct-pred struct)
(signal 'wrong-type-argument
(list ',struct-name struct)))
,(when accessor-init
`(unless (aref struct ,pos)
(aset struct ,pos ,accessor-init)))
;; NOTE: It's essential that this `aref' form be last
;; so the gv-setter works in the compiler macro.
(aref struct ,pos))))
(push accessor-form accessor-forms)
;; Remove accessor forms from `cl-defstruct' expansion. This may be distasteful,
;; but it would seem more distasteful to copy all of `cl-defstruct' and potentially
;; have the implementations diverge in the future when Emacs changes (e.g. the new
;; record type).
(cl-loop for form in-ref cl-defstruct-expansion
do (pcase form
(`(cl-defsubst ,(and accessor (guard (eq accessor accessor-name)))
. ,_)
accessor ; Silence "unused lexical variable" warning.
(setf form nil))))
;; Alias definitions.
(cl-loop for alias in aliases
for alias-name = (intern (concat (symbol-name struct-name) "-" (symbol-name alias)))
do (push `(defalias ',alias-name ',accessor-name) alias-forms))
;; TODO: Setter
;; ,(when (plist-get slot-options :reset)
;; `(gv-define-setter ,accessor-name (ts value)
;; `(progn
;; (aset ,ts ,,pos ,value)
;; (setf (ts-unix ts) ni))))
))
`(progn
,cl-defstruct-expansion
,@accessor-forms
,@alias-forms)))
;; TODO: When a field is changed, the unix/internal slot needs to be updated. On the other hand,
;; maybe not. Maybe `ts-adjust' should be the only way to adjust them. Otherwise, updating the
;; unix/internal time value when a slot is changed gets really complicated, and it might not be
;; worth it. Or, at least, not in the initial version.
(ts-defstruct ts
(hour
nil :accessor-init (string-to-number (format-time-string "%H" (ts-unix struct)))
:aliases (H)
:constructor "%H"
:type integer)
(minute
nil :accessor-init (string-to-number (format-time-string "%M" (ts-unix struct)))
:aliases (min M)
:constructor "%M"
:type integer)
(second
nil :accessor-init (string-to-number (format-time-string "%S" (ts-unix struct)))
:aliases (sec S)
:constructor "%S"
:type integer)
(day
nil :accessor-init (string-to-number (format-time-string "%d" (ts-unix struct)))
:aliases (day-of-month-num dom d)
:constructor "%d"
:type integer)
(month
nil :accessor-init (string-to-number (format-time-string "%m" (ts-unix struct)))
:aliases (month-num moy m)
:constructor "%m"
:type integer)
(year
nil :accessor-init (string-to-number (format-time-string "%Y" (ts-unix struct)))
:aliases (Y)
:constructor "%Y"
:type integer)
(dow
nil :accessor-init (string-to-number (format-time-string "%w" (ts-unix struct)))
:aliases (day-of-week-num)
:constructor "%w"
:type integer)
(day-abbr
nil :accessor-init (format-time-string "%a" (ts-unix struct))
:aliases (day-of-week-abbr)
:constructor "%a")
(day-name
nil :accessor-init (format-time-string "%A" (ts-unix struct))
:aliases (day-of-week-name)
:constructor "%A")
;; (doe nil
;; :accessor-init (days-between (format-time-string "%Y-%m-%d 00:00:00" (ts-unix struct))
;; "1970-01-01 00:00:00")
;; :aliases (day-of-epoch))
(doy
nil :accessor-init (string-to-number (format-time-string "%j" (ts-unix struct)))
:aliases (day-of-year)
:constructor "%j"
:type integer)
(woy
nil :accessor-init (string-to-number (format-time-string "%V" (ts-unix struct)))
:aliases (week week-of-year)
:constructor "%V"
:type integer)
(month-abbr
nil :accessor-init (format-time-string "%b" (ts-unix struct))
:aliases (b)
:constructor "%b")
(month-name
nil :accessor-init (format-time-string "%B" (ts-unix struct))
:aliases (B)
:constructor "%B")
(tz-abbr
nil :accessor-init (format-time-string "%Z" (ts-unix struct))
:constructor "%Z")
(tz-offset
nil :accessor-init (format-time-string "%z" (ts-unix struct))
:constructor "%z")
;; MAYBE: Add tz-offset-minutes
(internal
nil :accessor-init (apply #'encode-time (decode-time (ts-unix struct))))
(unix
nil :accessor-init (pcase-let* (((cl-struct ts second minute hour day month year) struct))
(if (and second minute hour day month year)
(float-time (encode-time second minute hour day month year))
(float-time)))))
;;;; Substs
(defun ts-now ()
"Return `ts' struct set to now.
This is a non-inlined function, so it may be rebound, e.g. with
`cl-letf' for testing."
(make-ts :unix (float-time)))
(defsubst ts-format (&optional ts-or-format-string ts)
"Format timestamp with `format-time-string'.
If TS-OR-FORMAT-STRING is a timestamp or nil, use the value of
`ts-default-format'. If both TS-OR-FORMAT-STRING and TS are nil,
use the current time."
(cl-etypecase ts-or-format-string
(ts (format-time-string ts-default-format (ts-unix ts-or-format-string)))
(string (cl-etypecase ts
(ts (format-time-string ts-or-format-string (ts-unix ts)))
(null (format-time-string ts-or-format-string))))
(null (cl-etypecase ts
(ts (format-time-string ts-default-format (ts-unix ts)))
(null (format-time-string ts-default-format))))))
(defsubst ts-parse (string)
"Return new `ts' struct, parsing STRING with `parse-time-string'."
(let ((parsed (parse-time-string string)))
;; Fill nil values
(cl-loop for i from 0 to 5
when (null (nth i parsed))
do (setf (nth i parsed) 0))
(->> parsed
(apply #'encode-time)
float-time
(make-ts :unix))))
(defsubst ts-parse-fill (fill string)
"Return new `ts' struct, parsing STRING with `parse-time-string'.
Empty hour/minute/second values are filled according to FILL: if
`begin', with 0; if `end', hour is filled with 23 and
minute/second with 59; if nil, an error may be signaled when time
values are empty.
Note that when FILL is `end', a time value like \"12:12\" is
filled to \"12:12:00\", not \"12:12:59\"."
(let ((parsed (parse-time-string string)))
;; Fill nil values
(pcase-exhaustive fill
('begin (unless (nth 0 parsed)
(setf (nth 0 parsed) 0))
(unless (nth 1 parsed)
(setf (nth 1 parsed) 0))
(unless (nth 2 parsed)
(setf (nth 2 parsed) 0)))
;; NOTE: When the second value is not present in the string, it's
;; set to 0, even when FILL is `end'. In a way this seems wrong,
;; but on the other hand, "12:12" as a plain time value is assumed
;; to refer to the moment it becomes 12:12, which means 0 seconds.
('end (unless (nth 0 parsed)
(setf (nth 0 parsed) 59))
(unless (nth 1 parsed)
(setf (nth 1 parsed) 59))
(unless (nth 2 parsed)
(setf (nth 2 parsed) 23)))
(`nil nil))
(->> parsed
(apply #'encode-time)
float-time
(make-ts :unix))))
(defsubst ts-reset (ts)
"Return TS with all slots cleared except `unix'.
Non-destructive. The same as:
(make-ts :unix (ts-unix ts))"
(make-ts :unix (ts-unix ts)))
(defsubst ts-update (ts)
"Return timestamp TS after updating its Unix timestamp from its other slots.
Non-destructive. To be used after setting slots with,
e.g. `ts-fill'."
(pcase-let* (((cl-struct ts second minute hour day month year) ts))
(make-ts :unix (float-time (encode-time second minute hour day month year)))))
(defsubst ts-parse-org-element (element)
"Return timestamp object for Org timestamp element ELEMENT.
Element should be like one parsed by `org-element', the first
element of which is `timestamp'. Assumes timestamp is not a
range."
(-let (((_ (&keys :year-start :month-start :day-start :hour-start :minute-start)) element))
(make-ts :year year-start :month month-start :day day-start
:hour (or hour-start 0) :minute (or minute-start 0) :second 0)))
;; We don't want to force `org' to load when this library does, so we declare
;; the function. Users should load `org' before calling `ts-parse-org'.
(declare-function org-parse-time-string "org-macs.el")
(defsubst ts-parse-org (org-ts-string)
"Return timestamp object for Org timestamp string ORG-TS-STRING.
Note that function `org-parse-time-string' is called, which
should be loaded before calling this function."
(pcase-let* ((`(,second ,minute ,hour ,day ,month ,year)
(save-match-data
(org-parse-time-string org-ts-string))))
(make-ts :second second :minute minute :hour hour :day day :month month :year year)))
(defsubst ts-parse-org-fill (fill org-ts-string)
"Return timestamp object for Org timestamp string ORG-TS-STRING.
Note that function `org-parse-time-string' is called, which
should be loaded before calling this function.
Hour/minute/second values are filled according to FILL: if
`begin', with 0; if `end', hour is filled with 23 and
minute/second with 59. Note that `org-parse-time-string' does
not support timestamps that contain seconds."
(pcase-let* ((`(,second ,minute ,hour ,day ,month ,year)
(org-parse-time-string org-ts-string 'nodefault)))
(pcase-exhaustive fill
('begin (unless second
(setf second 0))
(unless minute
(setf minute 0))
(unless hour
(setf hour 0)))
('end (if (not (or hour minute))
(progn
;; `hour' and `minute' are nil, so set them and `second'.
;; `org-parse-time-string' sets the second to 0 even if
;; NODEFAULT is non-nil.
(setf second 59
minute 59
hour 23))
;; FIXME: Some of these could be omitted.
(unless second
(setf second 59))
(unless minute
(setf minute 59))
(unless hour
(setf hour 23))))
(_ (error "FILL must be `begin' or `end'")))
(make-ts :second second :minute minute :hour hour :day day :month month :year year)))
;;;; Functions
(cl-defun ts-apply (&rest args)
"Return new timestamp based on TS with new slot values from ARGS.
Fill timestamp slots, overwrite given slot values, and return new
timestamp with Unix timestamp value derived from new slot values.
SLOTS is a list of alternating key-value pairs like that passed
to `make-ts'."
(declare (advertised-calling-convention (&rest slots ts) nil))
(-let* (((&keys :second :minute :hour :day :month :year) args)
(ts (-last-item args)))
;; MAYBE: Add timezone offset?
(setf ts (ts-fill ts))
(when second
(setf (ts-second ts) second))
(when minute
(setf (ts-minute ts) minute))
(when hour
(setf (ts-hour ts) hour))
(when day
(setf (ts-day ts) day))
(when month
(setf (ts-month ts) month))
(when year
(setf (ts-year ts) year))
(ts-update ts)))
(defmacro ts-define-fill ()
"Define function that fills all applicable slots of a `ts' from its `unix' slot."
(let* ((slots (->> (cl-struct-slot-info 'ts)
(--select (and (not (member (car it) '(unix internal cl-tag-slot)))
(plist-get (cddr it) :constructor)))
(--map (list (intern (concat ":" (symbol-name (car it))))
(cddr it)))))
(keywords (-map #'car slots))
(constructors (->> slots
(--map (plist-get (cadr it) :constructor))
-non-nil))
(types (--map (plist-get (cadr it) :type) slots))
(format-string (s-join "\f" constructors))
(value-conversions (cl-loop for type in types
for keyword in keywords
for i from 0
for val = `(nth ,i time-values)
append (list keyword (pcase type
('integer `(string-to-number ,val))
(_ val))))))
;; MAYBE: Construct the record manually? Probably not worth it, but might eke out a bit more speed.
`(defun ts-fill (ts &optional zone)
"Return TS having filled all slots from its Unix timestamp.
This is non-destructive. ZONE is passed to `format-time-string',
which see."
;; MAYBE: Use `decode-time' instead of `format-time-string'? It provides most of the values we need. Should benchmark.
(let ((time-values (save-match-data
(split-string (format-time-string ,format-string (ts-unix ts) zone) "\f"))))
(make-ts :unix (ts-unix ts) ,@value-conversions)))))
(ts-define-fill)
;; FIXME: This fails, and I'm not sure if it's a limitation of gvs or if I did something wrong:
;; (ts-incf (ts-moy (ts-now)))
(defun ts-difference (a b)
"Return difference in seconds between timestamps A and B."
;; MAYBE: Use absolute values so arg order doesn't matter.
(- (ts-unix a) (ts-unix b)))
(defalias 'ts-diff 'ts-difference)
(defun ts-human-duration (seconds)
"Return plist describing duration SECONDS.
List includes years, days, hours, minutes, and seconds. This is
a simple calculation that does not account for leap years, leap
seconds, etc."
;; TODO: Add weeks.
(cl-macrolet ((dividef (place divisor)
;; Divide PLACE by DIVISOR, set PLACE to the remainder, and return the quotient.
`(prog1 (/ ,place ,divisor)
(setf ,place (% ,place ,divisor)))))
(let* ((seconds (floor seconds))
(years (dividef seconds 31536000))
(days (dividef seconds 86400))
(hours (dividef seconds 3600))
(minutes (dividef seconds 60)))
(list :years years :days days :hours hours :minutes minutes :seconds seconds))))
;; See also the built-in function `format-seconds', which I seem to have
;; overlooked before writing this. However, a quick benchmark, run
;; 100,000 times, shows that, when controllable formatting is not needed,
;; `ts-human-format-duration' is much faster and generates less garbage:
;; | Form | x faster than next | Total runtime | # of GCs | Total GC runtime |
;; |--------------------------+--------------------+---------------+----------+------------------|
;; | ts-human-format-duration | 5.82 | 0.832945 | 3 | 0.574929 |
;; | format-seconds | slowest | 4.848253 | 17 | 3.288799 |
(cl-defun ts-human-format-duration (seconds &optional abbreviate)
"Return human-formatted string describing duration SECONDS.
If SECONDS is less than 1, returns \"0 seconds\". If ABBREVIATE
is non-nil, return a shorter version, without spaces. This is a
simple calculation that does not account for leap years, leap
seconds, etc."
;; FIXME: Doesn't work with negative values, even though `ts-human-duration' does.
(if (< seconds 1)
(if abbreviate "0s" "0 seconds")
(cl-macrolet ((format> (place)
;; When PLACE is greater than 0, return formatted string using its symbol name.
`(when (> ,place 0)
(format "%d%s%s" ,place
(if abbreviate "" " ")
(if abbreviate
,(substring (symbol-name place) 0 1)
,(symbol-name place)))))
(join-places (&rest places)
;; Return string joining the names and values of PLACES.
`(->> (list ,@(cl-loop for place in places
collect `(format> ,place)))
-non-nil
(s-join (if abbreviate "" ", ")))))
(-let* (((&plist :years :days :hours :minutes :seconds) (ts-human-duration seconds)))
(join-places years days hours minutes seconds)))))
;;;;; Adjustors
;; These functions are very cool, and they may make the adjust function unnecessary, because you can
;; do something like (ts-adjust 'moy 120 (ts-now)) and get a timestamp 10 years in the future.
;; FIXME: Note that not all slots can be used to adjust the timestamp.
;; For example, the day-of-week-num slot doesn't have any effect.
;;;;;; Non-destructive
;; These non-destructive versions take the slot symbol as an argument and the object last, and they
;; return the timestamp object rather than the new slot value, making them suitable for use in
;; threading macros when the initial form is a sexp rather than a value or variable.
(defun ts-adjust (&rest adjustments)
"Return new timestamp having applied ADJUSTMENTS to TS.
ADJUSTMENTS should be a series of alternating SLOTS and VALUES by
which to adjust them. For example, this form returns a new
timestamp that is 47 hours into the future:
(ts-adjust 'hour -1 'day +2 (ts-now))
Since the timestamp argument is last, it's suitable for use in a
threading macro."
(declare (advertised-calling-convention (&rest adjustments ts) nil))
(let* ((ts (-last-item adjustments))
(adjustments (nbutlast adjustments))
(ts (ts-fill ts)))
(cl-loop for (slot change) on adjustments by #'cddr
do (cl-incf (cl-struct-slot-value 'ts slot ts) change))
(ts-update ts)))
(defsubst ts-inc (slot value ts)
"Return a new timestamp based on TS with its SLOT incremented by VALUE.
SLOT should be specified as a plain symbol, not a keyword."
(setq ts (ts-fill ts))
(cl-incf (cl-struct-slot-value 'ts slot ts) value)
(ts-update ts))
(defsubst ts-dec (slot value ts)
"Return a new timestamp based on TS with its SLOT decremented by VALUE.
SLOT should be specified as a plain symbol, not a keyword."
(setq ts (ts-fill ts))
(cl-decf (cl-struct-slot-value 'ts slot ts) value)
(ts-update ts))
;;;;;; Generalized variables
;; These destructive versions act like `cl-incf'. They are slightly less suitable for use in
;; threading macros, because it's not possible to do, e.g. this:
;; (-> (ts-now)
;; (ts-adjustf 'day 1))
;; ...because `ts-now' doesn't return a generalized variable. But this still works:
;; (let ((ts (ts-now)))
;; (-> ts (ts-adjustf 'dom 1)))
;; TODO: Look at `cl-incf' implementation, consider whether we should imitate it.
(defmacro ts-adjustf (ts &rest adjustments)
"Return timestamp TS having applied ADJUSTMENTS.
This function is destructive, as it calls `setf' on TS.
ADJUSTMENTS should be a series of alternating SLOTS and VALUES by
which to adjust them. For example, this form adjusts a timestamp
to 47 hours into the future:
(let ((ts (ts-now)))
(ts-adjustf ts 'hour -1 'day +2))"
;; MAYBE: Is it possible to make this kind of macro work in a threading macro by taking its TS
;; argument last? It only seems to work if the TS is a symbol rather than a form, because of how
;; generalized variables work, but that makes it less useful and more error-prone.
`(progn
;; We use the accessor functions rather than `cl-struct-slot-value', because it's slightly
;; faster to use the accessors, even though `cl-struct-slot-value' is supposed to be
;; byte-compiled to essentially the same thing (although it's possible I'm doing something
;; wrong).
(setf ,ts (ts-fill ,ts))
,@(cl-loop for (slot change) on adjustments by #'cddr
for accessor = (intern (concat "ts-" (symbol-name (cadr slot))))
collect `(cl-incf (,accessor ,ts) ,change))
(setf ,ts (ts-update ,ts))))
(cl-defmacro ts-incf (place &optional (value 1))
"Increment `ts' PLACE by VALUE (default 1) and return the new value of PLACE.
Updates its `unix' slot accordingly."
`(progn
(setf ,(cadr place) (ts-fill ,(cadr place)))
(prog1
(cl-incf ,place ,value)
(setf ,(cadr place)
(ts-update ,(cadr place))))))
(cl-defmacro ts-decf (place &optional (value 1))
"Decrement `ts' PLACE by VALUE (default 1) and return the new value of PLACE.
Updates its `unix' slot accordingly."
`(progn
(setf ,(cadr place) (ts-fill ,(cadr place)))
(prog1
(cl-decf ,place ,value)
(setf ,(cadr place)
(ts-update ,(cadr place))))))
;;;;; Comparators
(defsubst ts-in (beg end ts)
"Return non-nil if TS is within range BEG to END, inclusive.
All arguments should be `ts' structs."
(and (ts<= beg ts)
(ts<= ts end)))
(defun ts= (a b)
"Return non-nil if timestamp A is the same as timestamp B.
Compares only the timestamps' `unix' slots. Note that a
timestamp's Unix slot is a float and may differ by less than one
second, causing them to be unequal even if all of the formatted
parts of the timestamp are the same."
(= (ts-unix a) (ts-unix b)))
(defun ts< (a b)
"Return non-nil if timestamp A is less than timestamp B."
(< (ts-unix a) (ts-unix b)))
(defun ts<= (a b)
"Return non-nil if timestamp A is <= timestamp B."
(<= (ts-unix a) (ts-unix b)))
(defun ts> (a b)
"Return non-nil if timestamp A is greater than timestamp B."
(> (ts-unix a) (ts-unix b)))
(defun ts>= (a b)
"Return non-nil if timestamp A is >= timestamp B."
(>= (ts-unix a) (ts-unix b)))
;;;; Footer
(provide 'ts)
;;; ts.el ends here
(define-package "ts" "20220822.2313" "Timestamp and date/time library"
'((emacs "26.1")
(dash "2.14.1")
(s "1.12.0"))
:commit "552936017cfdec89f7fc20c254ae6b37c3f22c5b" :authors
'(("Adam Porter" . "adam@alphapapa.net"))
:maintainers
'(("Adam Porter" . "adam@alphapapa.net"))
:maintainer
'("Adam Porter" . "adam@alphapapa.net")
:keywords
'("calendar" "lisp")
:url "http://github.com/alphapapa/ts.el")
;; Local Variables:
;; no-byte-compile: t
;; End:
;;; ts-autoloads.el --- automatically extracted autoloads -*- lexical-binding: t -*-
;;
;;; Code:
(add-to-list 'load-path (directory-file-name
(or (file-name-directory #$) (car load-path))))
;;;### (autoloads nil "ts" "ts.el" (0 0 0 0))
;;; Generated autoloads from ts.el
(register-definition-prefixes "ts" '("ts-" "ts<" "ts=" "ts>"))
;;;***
;;;### (autoloads nil nil ("ts-pkg.el") (0 0 0 0))
;;;***
;; Local Variables:
;; version-control: never
;; no-byte-compile: t
;; no-update-autoloads: t
;; coding: utf-8
;; End:
;;; ts-autoloads.el ends here
Good signature from 066DAFCB81E42C40 GNU ELPA Signing Agent (2019) <elpasign@elpa.gnu.org> (trust undefined) created at 2022-09-13T17:10:02-0400 using RSA
(require 'persist)
(require 'seq)
(defmacro with-local-temp-persist (&rest body)
(declare (debug body))
`(unwind-protect
(let ((persist--directory-location "./persist/")
(persist--symbols nil))
,@body)
(delete-directory "./persist" t)))
(ert-deftest test-persist-symbol ()
(should
(let ((persist--symbols nil)
(sym (cl-gensym)))
(persist-symbol sym 10)
(seq-contains persist--symbols sym))))
(ert-deftest test-persist-save-only-persistant ()
;; do not save not persist variables
(should-error
(with-local-temp-persist
(persist-save (cl-gensym)))
:type 'error
:exclude-subtypes t))
(ert-deftest test-persist-save ()
(with-local-temp-persist
(let ((sym (cl-gensym)))
;; precondition
(should-not (file-exists-p (persist--file-location sym)))
(set sym 10)
(persist-symbol sym 10)
(persist-save sym)
(should t)
(should-not (file-exists-p (persist--file-location sym)))
(set sym 20)
(persist-save sym)
(should (file-exists-p (persist--file-location sym)))
(should
(string-match-p
"20"
(with-temp-buffer
(insert-file-contents (persist--file-location sym))
(buffer-string))))
(set sym 10)
(persist-save sym)
(should-not (file-exists-p (persist--file-location sym)))
(should-error
(persist-save 'fred)))))
(ert-deftest test-persist-save-non-number ()
"Test saving something that is not a number.
`test-persist-save' missed "
(with-local-temp-persist
(let ((sym (cl-gensym)))
(set sym "fred")
(persist-symbol sym "fred")
(persist-save sym)
(should t)
(should-not (file-exists-p (persist--file-location sym)))
(set sym "george")
(persist-save sym)
(should (file-exists-p (persist--file-location sym)))
(should
(string-match-p
"george"
(with-temp-buffer
(insert-file-contents (persist--file-location sym))
(buffer-string)))))))
(ert-deftest test-persist-load ()
(with-local-temp-persist
(let ((sym (cl-gensym)))
(set sym 10)
;; set this different to force save
(persist-symbol sym 1)
(persist-save sym)
(should (equal 10 (symbol-value sym)))
(set sym 30)
(should (equal 30 (symbol-value sym)))
(persist-load sym)
(should (equal 10 (symbol-value sym))))))
(ert-deftest test-persist-remove ()
(with-local-temp-persist
(let ((sym (cl-gensym)))
(should-not (persist--persistant-p sym))
(persist-symbol sym 10)
(should (persist--persistant-p sym))
(persist-unpersist sym)
(should-not (persist--persistant-p sym)))))
(ert-deftest test-persist-defvar ()
(with-local-temp-persist
(defvar test-no-persist-variable 10 "docstring")
(persist-defvar test-persist-variable 20 "docstring")
(should-not (persist--persistant-p 'test-no-persist-variable))
(should (persist--persistant-p 'test-persist-variable))
(should (= 20
(persist-default 'test-persist-variable)))))
(ert-deftest test-persist-location ()
(unwind-protect
(let ((sym (cl-gensym)))
(delete-directory "./persist-defined-location" t)
(set sym 10)
(persist-symbol sym 10)
(persist-location sym "./persist-defined-location")
(should
(equal (expand-file-name
(symbol-name sym)
"./persist-defined-location/")
(persist--file-location sym)))
(persist-save sym)
(should-not (file-exists-p (persist--file-location sym)))
(set sym 20)
(persist-save sym)
(should (file-exists-p (persist--file-location sym)))
(should
(string-match-p
"20"
(with-temp-buffer
(insert-file-contents (persist--file-location sym))
(buffer-string))))
(should-error
(persist-save 'fred)))
(delete-directory "./persist-defined-location" t)))
\input texinfo
@setfilename persist.info
@settitle persist persistant variables
@dircategory Emacs
@direntry
* Persist: (persist). Persistant variables for Emacs.
@end direntry
@copying
Copyright @copyright{} 2019 Phillip Lord
@quotation
Permission is granted to copy, distribute and/or modify this document
under the terms of the GNU Free Documentation License, Version 1.2 or
any later version published by the Free Software Foundation; with no
Invariant Sections, with the Front-Cover, or Back-Cover Texts. A copy of
the license is included in the section entitled ``GNU Free Documentation
License'' in the Emacs manual.
This document is part of a collection distributed under the GNU Free
Documentation License. If you want to distribute this document
separately from the collection, you can do so by adding a copy of the
license to the document, as described in section 6 of the license.
All Emacs Lisp code contained in this document may be used, distributed,
and modified without restriction.
@end quotation
@end copying
@titlepage
@title Persist -- Persistant Variables for Emacs
@author by Phillip Lord
@page
@insertcopying
@end titlepage
@contents
@node Top
@top Persist -- Persistant Variables for Emacs
Perist is a library for making variables persistant; that it, their
state can be changed from the default and the new value will remain even
after Emacs has been closed and restarted.
@menu
* Persist:: Simple Usage
* The details:: All functions for interacting with Persist
* Comparison:: How this relates to other, similar techniques
* Implementation:: How variables are saved
@end menu
@node Persist
@section Persist
This section describes simple usage of persist.
@defmac persist-defvar (var initvalue docstring) body@dots{}
This macro is equivalent in form to @code{defvar}, except any changes to
the value of @code{var} will persist between sessions. This macro does
not support the lower arity versions of @code{defvar}. Both an
@code{initvalue} and @code{docstring} needs to be provided.
@end defmac
@example
@group
(persist-defvar my-persistant-variable 10
"A variable of no purpose.
This variable is persistant between sessions")
@end group
@end example
@node The details
@section The details
@defmac persist-defvar (var initvalue docstring) body@dots{}
This macro is equivalent to @code{defvar} and can be used to make a
variable persistant.
@end defmac
@defun persist-symbol (symbol &optional initvalue)
This function takes @code{symbol} for an existing, non-persistant variable
and makes it persistant. If @code{initvalue} is not given, then the
current value is used. For package developers, @code{persist-defvar}
would generally be prefered; this function might be useful for making
third-party variables persistant.
@end defun
@example
@group
(defvar my-persistant-variable 10
"A variable of no purpose")
(persist-symbol 'my-persistant-variable 10)
@end group
@end example
@defun persist-save (symbol)
This function saves @code{symbol} immediately rather than waiting till
the normal time
@end defun
@defun persist-default (symbol)
Return the default value for @code{symbol}. The default value is
actually set for each session and does not persist from session to
session, although if the value is set by either @code{persist-defvar} or
@code{persist-symbol} saved it in a file, it will be set to the same
value across sessions.
@end defun
@defun persist-reset (symbol)
Change the value of @code{symbol} to the last saved value if it exists.
@end defun
@defun persist-location (symbol directory)
Values are usually persisted to a standard location; it is possible to
change this for individual symbol using this function. Be aware that
this does not call @code{persist-load}, so this will not restore a
previously saved value.
@end defun
@node Comparison
@section Comparison
There are several other packages which also persist aspects of Emacs
across sessions, however, these fulfil a different purpose.
Custom persists values of variables across sessions. However, it does
this for user options, and is associated with a user interface for
editing the value.
desktop.el is also user-centric and is aimed at persisting the session
in terms of buffers, modes and minor modes. It can be used to persist
individual variables, but this will also save the session which the user
may or may not want.
savehist.el can save individual variables but, as with desktop.el, is a
a global setting and has other implications such as saving mini-buffer
history.
@node Implementation
@section Implementation
persist is implemented by saving values for each symbol into an
different. This makes it relatively easy to update or delete the stored
value for a variable by hand if necessary. It should scale to 10 or 100
variables, but may get a bit slow after this.
@bye
;;; persist.el --- Persist Variables between Emacs Sessions -*- lexical-binding: t -*-
;; Copyright (C) 2019 Free Software Foundation, Inc.
;; Author: Phillip Lord <phillip.lord@russet.org.uk>
;; Maintainer: Phillip Lord <phillip.lord@russet.org.uk>
;; Package-Type: multi
;; Version: 0.5
;; The contents of this file are subject to the GPL License, Version 3.0.
;; This file is not part of Emacs
;; This program is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; This package provides variables which persist across sessions.
;; The main entry point is `persist-defvar' which behaves like
;; `defvar' but which persists the variables between session. Variables
;; are automatically saved when Emacs exits.
;; Other useful functions are `persist-save' which saves the variable
;; immediately, `persist-load' which loads the saved value,
;; `persist-reset' which resets to the default value.
;; Values are stored in a directory in `user-emacs-directory', using
;; one file per value. This makes it easy to delete or remove unused
;; variables.
;;; Code:
(defvar persist--directory-location
(locate-user-emacs-file "persist")
"The location of persist directory.")
(defvar persist--symbols nil
"List of symbols to persist.")
(defvar persist-load-hook nil
"Special hook run on loading a variable.
Hook functions are called with two values: the symbol and the
value it will be set to. If any function returns nil, the
variable is not set to the value.")
(defun persist--file-location (symbol)
"Return the file name at which SYMBOL does or will persist."
(expand-file-name
(symbol-name symbol)
(or (get symbol 'persist-location)
persist--directory-location)))
(defun persist--defvar-1 (symbol location)
"Set symbol up for persistance."
(when location
(persist-location symbol location))
(persist-symbol symbol (symbol-value symbol))
(persist-load symbol))
(defmacro persist-defvar (symbol initvalue docstring &optional location)
"Define SYMBOL as a persistant variable and return SYMBOL.
This form is nearly equivalent to `defvar', except that the
variable persists between Emacs sessions.
It does not support the optional parameters. Both INITVALUE and
DOCSTRING need to be given."
;; We cannot distinguish between calls with initvalue of nil and a
;; single parameter call. Unfortunately, these two calls have
;; different semantics -- the single arity shuts up the byte
;; compiler, but does not define the symbol. So, don't support a
;; single arity persist-defvar.
;; Don't support 2-arity calls either because we are lazy and
;; because if you want to persist it, you want to doc it.
(declare (debug (symbolp form stringp &optional form)) (doc-string 3))
;; Define inside progn so the byte compiler sees defvar
`(progn
(defvar ,symbol ,initvalue ,docstring)
;; Access initvalue through its symbol because the defvar form
;; has to stay at first level within a progn
(persist--defvar-1 ',symbol ,location)
',symbol))
(defun persist-location (symbol directory)
"Set the directory for persisting the value of symbol.
This does not force the loading of value from this directory, so
to persist a variable, you will normally need to call
`persist-load' to load a previously saved location."
(put symbol 'persist-location (expand-file-name directory)))
(defun persist-symbol (symbol &optional initvalue)
"Make SYMBOL a persistant variable.
If non-nil, INITVALUE is the value to which SYMBOL will be set if
`persist-reset' is called. Otherwise, the INITVALUE will be the
current `symbol-value' of SYMBOL.
INITVALUE is set for the session and will itself not persist
across sessions.
This does force the loading of value from this directory, so to
persist a variable, you will normally need to call `persist-load'
to load a previously saved location."
(let ((initvalue (or initvalue (symbol-value symbol))))
(add-to-list 'persist--symbols symbol)
(put symbol 'persist t)
(put symbol 'persist-default initvalue)))
(defun persist--persistant-p (symbol)
"Return non-nil if SYMBOL is a persistant variable."
(get symbol 'persist))
(defun persist-save (symbol)
"Save SYMBOL now.
Normally, it should not be necessary to call this explicitly, as
variables persist automatically when Emacs exits."
(unless (persist--persistant-p symbol)
(error (format
"Symbol %s is not persistant" symbol)))
(let ((symbol-file-loc (persist--file-location symbol)))
(if (equal (symbol-value symbol)
(persist-default symbol))
(when (file-exists-p symbol-file-loc)
(delete-file symbol-file-loc))
(let ((dir-loc
(file-name-directory symbol-file-loc)))
(unless (file-exists-p dir-loc)
(mkdir dir-loc))
(with-temp-buffer
(let (print-level
print-length
print-quoted
(print-escape-control-characters t)
(print-escape-nonascii t)
(print-circle t))
(print (symbol-value symbol) (current-buffer)))
(write-region (point-min) (point-max)
symbol-file-loc
nil 'quiet))))))
(defun persist-default (symbol)
"Return the default value for SYMBOL."
(get symbol 'persist-default))
(defun persist-reset (symbol)
"Reset the value of SYMBOL to the default."
(set symbol (persist-default symbol)))
(defun persist-load (symbol)
"Load the saved value of SYMBOL."
(when (file-exists-p (persist--file-location symbol))
(with-temp-buffer
(insert-file-contents (persist--file-location symbol))
(let ((val (read (current-buffer))))
(when (run-hook-with-args-until-failure 'persist-load-hook
symbol val)
(set symbol val))))))
(defun persist-unpersist (symbol)
"Stop the value in SYMBOL from persisting.
This does not remove any saved value of SYMBOL."
(put symbol 'persist nil)
(setq persist--symbols
(remove symbol persist--symbols)))
(defun persist--save-all ()
"Save all persistant symbols."
(mapc 'persist-save persist--symbols))
;; Save on kill-emacs-hook anyway
(add-hook 'kill-emacs-hook
'persist--save-all)
(provide 'persist)
;;; persist.el ends here
;; Generated package description from persist.el -*- no-byte-compile: t -*-
(define-package "persist" "0.5" "Persist Variables between Emacs Sessions" 'nil :commit "25d675307c03f720e592c3dc9a5a0ae8db0836eb" :url "https://elpa.gnu.org/packages/persist.html" :authors '(("Phillip Lord" . "phillip.lord@russet.org.uk")) :maintainer '("Phillip Lord" . "phillip.lord@russet.org.uk"))
;;; persist-autoloads.el --- automatically extracted autoloads -*- lexical-binding: t -*-
;;
;;; Code:
(add-to-list 'load-path (directory-file-name
(or (file-name-directory #$) (car load-path))))
;;;### (autoloads nil "persist" "persist.el" (0 0 0 0))
;;; Generated autoloads from persist.el
(register-definition-prefixes "persist" '("persist-"))
;;;***
;;;### (autoloads nil nil ("persist-pkg.el") (0 0 0 0))
;;;***
;; Local Variables:
;; version-control: never
;; no-byte-compile: t
;; no-update-autoloads: t
;; coding: utf-8
;; End:
;;; persist-autoloads.el ends here
Makefile
Cask
.gitignore
This is mastodon.info, produced by makeinfo version 6.7 from
mastodon.texi.
INFO-DIR-SECTION Emacs
START-INFO-DIR-ENTRY
* Mastodon: (mastodon). Client for Mastodon on ActivityPub networks.
END-INFO-DIR-ENTRY
File: mastodon.info, Node: Top, Next: README, Up: (dir)
* Menu:
* README::
— The Detailed Node Listing —
README
* Installation::
* Usage::
* Dependencies::
* Network compatibility::
* Contributing::
* Supporting ‘mastodon.el’: Supporting mastodonel.
* Contributors::
Installation
* MELPA::
* Emoji::
* Discover::
Usage
* Logging in to your instance::
* Timelines::
* Composing toots::
* Other commands and account settings::
* Customization::
* Alternative timeline layout::
* Live-updating timelines mastodon-async-mode::
* Translating toots::
* bookmarks and ‘mastodon.el’: bookmarks and mastodonel.
Contributing
* Bug reports::
* Fixes and features::
* Coding style::
File: mastodon.info, Node: README, Prev: Top, Up: Top
1 README
********
‘mastodon.el’ is an Emacs client for the AcitivityPub social networks
that implement the Mastodon API. For info see joinmastodon.org
(https://joinmastodon.org/).
* Menu:
* Installation::
* Usage::
* Dependencies::
* Network compatibility::
* Contributing::
* Supporting ‘mastodon.el’: Supporting mastodonel.
* Contributors::
File: mastodon.info, Node: Installation, Next: Usage, Up: README
1.1 Installation
================
Clone this repository and add the lisp directory to your load path.
Then, require it and go.
(add-to-list 'load-path "/path/to/mastodon.el/lisp")
(require 'mastodon)
Or, with ‘use-package’:
(use-package mastodon
:ensure t)
The minimum Emacs version is now 27.1. But if you are running an
older version it shouldn’t be very hard to get it working.
* Menu:
* MELPA::
* Emoji::
* Discover::
File: mastodon.info, Node: MELPA, Next: Emoji, Up: Installation
1.1.1 MELPA
-----------
Add ‘MELPA’ to your archives:
(require 'package)
(add-to-list 'package-archives
'("melpa" . "http://melpa.org/packages/") t)
Update and install:
‘M-x package-refresh-contents RET’
‘M-x package-install RET mastodon RET’
File: mastodon.info, Node: Emoji, Next: Discover, Prev: MELPA, Up: Installation
1.1.2 Emoji
-----------
‘mastodon-mode’ will enable Emojify
(https://github.com/iqbalansari/emacs-emojify) if it is loaded in your
Emacs environment, so there’s no need to write your own hook anymore.
‘emojify-mode’ is not required.
File: mastodon.info, Node: Discover, Prev: Emoji, Up: Installation
1.1.3 Discover
--------------
‘mastodon-mode’ can provide a context menu for its keybindings if
Discover (https://github.com/mickeynp/discover.el) is installed. It is
not required.
if you have Discover, add the following to your Emacs init
configuration:
(require 'mastodon-discover)
(with-eval-after-load 'mastodon (mastodon-discover))
Or, with ‘use-package’:
(use-package mastodon
:ensure t
:config
(mastodon-discover))
File: mastodon.info, Node: Usage, Next: Dependencies, Prev: Installation, Up: README
1.2 Usage
=========
* Menu:
* Logging in to your instance::
* Timelines::
* Composing toots::
* Other commands and account settings::
* Customization::
* Alternative timeline layout::
* Live-updating timelines mastodon-async-mode::
* Translating toots::
* bookmarks and ‘mastodon.el’: bookmarks and mastodonel.
File: mastodon.info, Node: Logging in to your instance, Next: Timelines, Up: Usage
1.2.1 Logging in to your instance
---------------------------------
You need to set 2 variables in your init file to get started:
1. ‘mastodon-instance-url’
2. ‘mastodon-active-user’
(see their doc strings for details). For example If you want to post
toots as "example_user@social.instance.org", then put this in your init
file:
(setq mastodon-instance-url "https://social.instance.org"
mastodon-active-user "example_user")
Then *restart* Emacs and run ‘M-x mastodon’. Make sure you are
connected to internet before you do this. If you have multiple mastodon
accounts you can activate one at a time by changing those two variables
and restarting Emacs.
If you were using mastodon.el before 2FA was implemented and the
above steps do not work, delete the old file specified by
‘mastodon-client--token-file’ and restart Emacs and follow the steps
again.
File: mastodon.info, Node: Timelines, Next: Composing toots, Prev: Logging in to your instance, Up: Usage
1.2.2 Timelines
---------------
‘M-x mastodon’
Opens a ‘*mastodon-home*’ buffer in the major mode and displays
toots. If your credentials are not yet saved, you will be prompted for
email and password. The app registration process will take place if
your ‘mastodon-token-file’ does not contain ‘:client_id’ and
‘:client_secret’.
1. Keybindings
Key Action
-----------------------------------------------------------------------------------------------------------
*Help*
‘?’ Show discover menu of all bindings, if ‘discover’ is available
*Timeline actions*
‘n’ Go to next item (toot, notification, user)
‘p’ Go to previous item (toot, notification, user)
‘M-n=/=<tab>’ Go to the next interesting thing that has an action
‘M-p=/=<S-tab>’ Go to the previous interesting thing that has an action
‘F’ Open federated timeline (1 prefix arg: hide-replies, 2 prefix args: media only)
‘H’ Open home timeline (1 prefix arg: hide-replies)
‘L’ Open local timeline (1 prefix arg: hide-replies, 2 prefix args: media only)
‘N’ Open notifications timeline
‘@’ Open mentions-only notifications timeline
‘u’ Update current timeline
‘T’ Open thread for toot at point
‘#’ Prompt for tag and open its timeline
‘A’ Open author profile of toot at point
‘P’ Open profile of user attached to toot at point
‘O’ View own profile
‘U’ update your profile bio note
‘;’ view instance description for toot at point
‘:’ view followed tags and load a tag timeline
‘C-:’ view timeline of all followed tags
‘,’ view favouriters of toot at point
‘.’ view boosters of toot at point
‘/’ switch between mastodon buffers
‘Z’ report user/toot at point to instances moderators
*Other views*
‘s’ search (posts, users, tags) (NB: only posts you have interacted with)
‘I’, ‘c’, ‘d’ view, create, and delete filters
‘R’, ‘a’, ‘j’ view/accept/reject follow requests
‘G’ view follow suggestions
‘V’ view your favourited toots
‘K’ view bookmarked toots
‘X’ view/edit/create/delete lists
‘S’ view your scheduled toots
*Toot actions*
‘t’ Compose a new toot
‘c’ Toggle content warning content
‘b’ Boost toot under ‘point’
‘f’ Favourite toot under ‘point’
‘k’ toggle bookmark of toot at point
‘r’ Reply to toot under ‘point’
‘v’ Vote on poll at point
‘C’ copy url of toot at point
‘C-RET’ play video/gif at point (requires ‘mpv’)
‘e’ edit your toot at point
‘E’ view edits of toot at point
‘i’ (un)pin your toot at point
‘d’ delete your toot at point, and reload current timeline
‘D’ delete and redraft toot at point, preserving reply/CW/visibility
(‘S-C-’) ‘W’, ‘M’, ‘B’ (un)follow, (un)mute, (un)block author of toot at point
*Profile view*
‘C-c C-c’ cycle between statuses, statuses without boosts, followers, and following
‘mastodon-profile--account-account-to-list’ (see lists view)
*Notifications view*
‘a’, ‘j’ accept/reject follow request
‘C-k’ clear notification at point
see ‘mastodon-notifications--get-*’ functions for filtered views
*Quitting*
‘q’ Quit mastodon buffer, leave window open
‘Q’ Quit mastodon buffer and kill window
‘C-M-q’ Quit and kill all mastodon buffers
2. Toot byline legend
Marker Meaning
--------------------------------------------
‘(🔁)’ (or I boosted this toot
‘(B)’)
‘(⭐)’ (or I favourited this toot
‘(F)’)
‘(🔖)’ (or I bookmarked this toot
(‘K’))
File: mastodon.info, Node: Composing toots, Next: Other commands and account settings, Prev: Timelines, Up: Usage
1.2.3 Composing toots
---------------------
‘M-x mastodon-toot’ (or ‘t’ from a mastodon.el buffer) opens a new
buffer/window in ‘text-mode’ and ‘mastodon-toot’ minor mode. Enter the
contents of your toot here. ‘C-c C-c’ sends the toot. ‘C-c C-k’
cancels. Both actions kill the buffer and window. Further keybindings
are displayed in the buffer, and in the following subsection.
Replies preserve visibility status/content warnings, and include
boosters by default.
Server’s max toot length, and attachment previews, are shown.
You can download and use your instance’s custom emoji
(‘mastodon-toot--download-custom-emoji’,
‘mastodon-toot--enable-custom-emoji’).
The compose buffer uses ‘text-mode’ so any configuration you have for
that mode will be enabled. If any of your existing config conflicts
with ‘mastodon-toot’, you can disable it in the
‘mastodon-toot-mode-hook’. For example, the default value of that hook
is as follows:
(add-hook 'mastodon-toot-mode-hook
(lambda ()
(auto-fill-mode -1)))
1. Keybindings
Key Action
-------------------------------------------------
‘C-c C-c’ Send toot
‘C-c C-k’ Cancel toot
‘C-c C-w’ Add content warning
‘C-c C-v’ Change toot visibility
‘C-c C-n’ Add sensitive media/nsfw flag
‘C-c C-a’ Upload attachment(s)
‘C-c !’ Remove all attachments
‘C-c C-e’ Add emoji (if ‘emojify’ installed)
‘C-c C-p’ Create a poll
‘C-c C-l’ Set toot language
2. autocompletion of mentions and tags
Autocompletion of mentions and tags is provided by
‘completion-at-point-functions’ (capf) backends.
‘mastodon-toot--enable-completion’ is enabled by default. If you
want to enable ‘company-mode’ in the toot compose buffer, set
‘mastodon-toot--use-company-for-completion’ to ‘t’. (‘mastodon.el’
used to run its own native company backends, but these have been
removed in favour of capfs.)
If you don’t run ‘company’ and want immediate, keyless completion,
you’ll need to have another completion engine running that handles
capfs. A common combination is ‘consult’ and ‘corfu’.
3. Draft toots
• Compose buffer text is saved as you type, kept in
‘mastodon-toot-current-toot-text’.
• ‘mastodon-toot--save-draft’: save the current toot as a draft.
• ‘mastodon-toot--open-draft-toot’: Open a compose buffer and
insert one of your draft toots.
• ‘mastodon-toot--delete-draft-toot’: Delete a draft toot.
• ‘mastodon-toot--delete-all-drafts’: Delete all your drafts.
File: mastodon.info, Node: Other commands and account settings, Next: Customization, Prev: Composing toots, Up: Usage
1.2.4 Other commands and account settings:
------------------------------------------
In addition to ‘mastodon’, the following three functions are autoloaded
and should work without first loading ‘mastodon.el’:
• ‘mastodon-toot’: Compose new toot
• ‘mastodon-notifications-get’: View all notifications
• ‘mastodon-url-lookup’: Attempt to load a URL in ‘mastodon.el’. URL
may be at point or provided in the minibuffer.
• ‘mastodon-tl--view-instance-description’: View information about
the instance that the author of the toot at point is on.
• ‘mastodon-tl--view-own-instance’: View information about your own
instance.
• ‘mastodon-search--trending-tags’: View a list of trending hashtags
on your instance.
• ‘mastodon-search--trending-statuses’: View a list of trending
statuses on your instance.
• ‘mastodon-tl--add-toot-account-at-point-to-list’: Add the account
of the toot at point to a list.
• ‘mastodon-tl--dm-user’: Send a direct message to one of the users
at point.
• ‘mastodon-profile--add-private-note-to-account’: Add a private note
to another user’s account.
• ‘mastodon-profile--view-account-private-note’: View a private note
on a user’s account.
• ‘mastodon-profile--show-familiar-followers’: Show a list of
“familiar followers” for a given account. Familiar followers are
accounts that you follow, and that follow the account.
• ‘mastodon-tl--follow-tag’: Follow a tag (works like following a
user)
• ‘mastodon-tl--unfollow-tag’: Unfollow a tag
• ‘mastodon-tl--list-followed-tags’: View a list of tags you’re
following.
• ‘mastodon-tl--followed-tags-timeline’: View a timeline of all your
followed tags.
• ‘mastodon-tl--some-followed-tags-timleine’: View a timeline of
multiple tags, from your followed tags or any other.
• ‘mastodon-switch-to-buffer’: switch between mastodon buffers.
• ‘mastodon-profile--update-display-name’: Update the display name
for your account.
• ‘mastodon-profile--update-user-profile-note’: Update your bio note.
• ‘mastodon-profile--update-meta-fields’: Update your metadata
fields.
• ‘mastodon-profile--set-default-toot-visibility’: Set the default
visibility for your toots.
• ‘mastodon-profile--account-locked-toggle’: Toggle the locked status
of your account. Locked accounts have to manually approve follow
requests.
• ‘mastodon-profile--account-discoverable-toggle’: Toggle the
discoverable status of your account. Non-discoverable accounts are
not listed in the profile directory.
• ‘mastodon-profile--account-bot-toggle’: Toggle whether your account
is flagged as a bot.
• ‘mastodon-profile--account-sensitive-toggle’: Toggle whether your
posts are marked as sensitive (nsfw) by default.
File: mastodon.info, Node: Customization, Next: Alternative timeline layout, Prev: Other commands and account settings, Up: Usage
1.2.5 Customization
-------------------
See ‘M-x customize-group RET mastodon’ to view all customize options.
• Timeline options:
• Use proportional fonts
• Default number of posts displayed
• Timestamp format
• Relative timestamps
• Display user avatars
• Avatar image height
• Enable image caching
• Hide replies in timelines
• Show toot stats in byline
• Compose options:
• Completion style for mentions and tags
• Enable custom emoji
• Display toot being replied to
• Set default reply visibility
File: mastodon.info, Node: Alternative timeline layout, Next: Live-updating timelines mastodon-async-mode, Prev: Customization, Up: Usage
1.2.6 Alternative timeline layout
---------------------------------
The incomparable Nicholas Rougier has written an alternative timeline
layout for ‘mastodon.el’.
The repo is at mastodon-alt
(https://github.com/rougier/mastodon-alt).
File: mastodon.info, Node: Live-updating timelines mastodon-async-mode, Next: Translating toots, Prev: Alternative timeline layout, Up: Usage
1.2.7 Live-updating timelines: ‘mastodon-async-mode’
----------------------------------------------------
(code taken from mastodon-future
(https://github.com/alexjgriffith/mastodon-future.el).)
Works for federated, local, and home timelines and for notifications.
It’s a little touchy, one thing to avoid is trying to load a timeline
more than once at a time. It can go off the rails a bit, but it’s still
pretty cool. The current maintainer of ‘mastodon.el’ is unable to debug
or improve this feature.
To enable, it, add ‘(require 'mastodon-async)’ to your ‘init.el’.
Then you can view a timeline with one of the commands that begin with
‘mastodon-async--stream-’.
File: mastodon.info, Node: Translating toots, Next: bookmarks and mastodonel, Prev: Live-updating timelines mastodon-async-mode, Up: Usage
1.2.8 Translating toots
-----------------------
You can translate toots with ‘mastodon-toot--translate-toot-text’ (‘a’
in a timeline). At the moment this requires lingva.el
(https://codeberg.org/martianh/lingva.el), a little interface I wrote to
lingva.ml (https://lingva.ml), to be installed to work.
You could easily modify the simple function to use your Emacs
translator of choice (‘libretrans.el’ , ‘google-translate’, ‘babel’,
‘go-translate’, etc.), you just need to fetch the toot’s content with
‘(mastodon-tl--content toot)’ and pass it to your translator function as
its text argument. Here’s what ‘mastodon-toot--translate-toot-text’
looks like:
(defun mastodon-toot--translate-toot-text ()
"Translate text of toot at point.
Uses `lingva.el'."
(interactive)
(let* ((toot (mastodon-tl--property 'toot-json)))
(if toot
(lingva-translate nil (mastodon-tl--content toot))
(message "No toot to translate?"))))
File: mastodon.info, Node: bookmarks and mastodonel, Prev: Translating toots, Up: Usage
1.2.9 bookmarks and ‘mastodon.el’
---------------------------------
‘mastodon.el’ doesn’t currently implement its own bookmark record and
handler, which means that emacs bookmarks will not work as is. Until we
implement them, you can get bookmarks going immediately by using
bookmark+.el
(https://github.com/emacsmirror/emacswiki.org/blob/master/bookmark%2b.el).
File: mastodon.info, Node: Dependencies, Next: Network compatibility, Prev: Usage, Up: README
1.3 Dependencies
================
Hard dependencies (should all install with ‘mastodon.el’):
• ‘request’ (for uploading attachments), emacs-request
(https://github.com/tkf/emacs-request)
• ‘persist’ for storing some settings across sessions
• ‘ts’ for poll relative expiry times
Optional dependencies:
• ‘emojify’ for inserting and viewing emojis
• ‘mpv’ and ‘mpv.el’ for viewing videos and gifs
• ‘lingva.el’ for translating toots
File: mastodon.info, Node: Network compatibility, Next: Contributing, Prev: Dependencies, Up: README
1.4 Network compatibility
=========================
‘mastodon.el’ should work with ActivityPub servers that implement the
Mastodon API.
Apart from Mastodon itself, it is currently known to work with
Pleroma and Gotosocial. If you attempt to use ‘mastodon.el’ with
another server that implements the Mastodon API and run into problems,
feel free to open an issue.
File: mastodon.info, Node: Contributing, Next: Supporting mastodonel, Prev: Network compatibility, Up: README
1.5 Contributing
================
PRs, issues, feature requests, and general feedback are very welcome!
* Menu:
* Bug reports::
* Fixes and features::
* Coding style::
File: mastodon.info, Node: Bug reports, Next: Fixes and features, Up: Contributing
1.5.1 Bug reports
-----------------
1. ‘mastodon.el’ has bugs, as well as lots of room for improvement.
2. I receive very little feedback, so if I don’t run into the bug it
often doesn’t get fixed.
3. If you run into something that seems broken, first try running
‘mastodon.el’ in emacs with no init file (i.e. ‘emacs -q’
(instructions and code for doing this are here
(https://codeberg.org/martianh/mastodon.el/issues/300)) to see if
it also happens independently of your own config (it probably
does).
4. Enable debug on error (‘toggle-debug-on-error’), make the bug
happen again, and copy the backtrace that appears.
5. Open an issue here and explain what is going on. Provide your
emacs version and what kind of server your account is on.
File: mastodon.info, Node: Fixes and features, Next: Coding style, Prev: Bug reports, Up: Contributing
1.5.2 Fixes and features
------------------------
1. Create an issue (https://codeberg.org/martianh/mastodon.el/issues)
detailing what you’d like to do.
2. Fork the repository and create a branch off of ‘develop’.
3. Run the tests and ensure that your code doesn’t break any of them.
4. Create a pull request referencing the issue created in step 1.
File: mastodon.info, Node: Coding style, Prev: Fixes and features, Up: Contributing
1.5.3 Coding style
------------------
• This library uses an unconvential double dash (‘--’) between file
namespaces and function names, which contradicts normal Elisp
style. This needs to be respected until the whole library is
changed.
• Use ‘aggressive-indent-mode’ or similar to keep your code indented.
• Single spaces end sentences in docstrings.
• There’s no need for a blank line after the first docstring line
(one is added automatically when documentation is displayed).
File: mastodon.info, Node: Supporting mastodonel, Next: Contributors, Prev: Contributing, Up: README
1.6 Supporting ‘mastodon.el’
============================
If you’d like to support continued development of ‘mastodon.el’, I
accept donations via paypal: paypal.me/martianh
(https://paypal.me/martianh). If you would prefer a different payment
method, write to me at that address and I can provide IBAN or other
details.
I don’t have a tech worker’s income, so even a small tip would help
out.
File: mastodon.info, Node: Contributors, Prev: Supporting mastodonel, Up: README
1.7 Contributors
================
‘mastodon.el’ is the work of a number of people.
Some significant contributors are:
• <https://github.com/jdenen> [original author]
• <http://atomized.org>
• <https://alexjgriffith.itch.io>
• <https://github.com/hdurer>
• <https://codeberg.org/Red_Starfish>
Tag Table:
Node: Top210
Node: README911
Node: Installation1327
Node: MELPA1863
Node: Emoji2231
Node: Discover2563
Node: Usage3115
Node: Logging in to your instance3525
Node: Timelines4522
Ref: Keybindings4997
Ref: Toot byline legend9570
Node: Composing toots9879
Ref: Keybindings (1)11118
Ref: autocompletion of mentions and tags11636
Ref: Draft toots12349
Node: Other commands and account settings12820
Node: Customization15978
Node: Alternative timeline layout16764
Node: Live-updating timelines mastodon-async-mode17154
Node: Translating toots18006
Node: bookmarks and mastodonel19188
Node: Dependencies19658
Node: Network compatibility20264
Node: Contributing20750
Node: Bug reports21039
Node: Fixes and features21945
Node: Coding style22428
Node: Supporting mastodonel23052
Node: Contributors23574
End Tag Table
Local Variables:
coding: utf-8
End:
;;; mastodon.el --- Client for fediverse services using the Mastodon API -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Copyright (C) 2021 Abhiseck Paira <abhiseckpaira@disroot.org>
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Package-Requires: ((emacs "27.1") (request "0.3.0") (persist "0.4") (ts "0.3"))
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon.el is a client for fediverse services that implement the Mastodon
;; API. See <https://github.com/mastodon/mastodon>.
;; See the readme file at https://codeberg.org/martianh/mastodon.el for set up
;; and usage details.
;;; Code:
(require 'cl-lib) ; for `cl-some' call in mastodon
(eval-when-compile (require 'subr-x))
(require 'mastodon-http)
(require 'mastodon-toot)
(require 'mastodon-search)
(require 'url)
(require 'thingatpt)
(require 'shr)
(declare-function discover-add-context-menu "discover")
(declare-function emojify-mode "emojify")
(declare-function request "request")
(autoload 'mastodon-auth--get-account-name "mastodon-auth")
(autoload 'mastodon-auth--user-acct "mastodon-auth")
(autoload 'mastodon-discover "mastodon-discover")
(autoload 'mastodon-notifications--follow-request-accept "mastodon-notifications")
(autoload 'mastodon-notifications--follow-request-reject "mastodon-notifications")
(autoload 'mastodon-notifications--get-mentions "mastodon-notifications")
(autoload 'mastodon-notifications--timeline "mastodon-notifications")
(autoload 'mastodon-profile--fetch-server-account-settings "mastodon-profile")
(autoload 'mastodon-profile--get-toot-author "mastodon-profile")
(autoload 'mastodon-profile--make-author-buffer "mastodon-profile")
(autoload 'mastodon-profile--my-profile "mastodon-profile")
(autoload 'mastodon-profile--show-user "mastodon-profile")
(autoload 'mastodon-profile--update-user-profile-note "mastodon-profile")
(autoload 'mastodon-profile--view-bookmarks "mastodon-profile")
(autoload 'mastodon-profile--view-favourites "mastodon-profile")
(autoload 'mastodon-tl--block-user "mastodon-tl")
(autoload 'mastodon-tl--follow-user "mastodon-tl")
(autoload 'mastodon-tl--followed-tags-timeline "mastodon-tl")
(autoload 'mastodon-tl--get-buffer-type "mastodon-tl")
(autoload 'mastodon-tl--get-federated-timeline "mastodon-tl")
(autoload 'mastodon-tl--get-home-timeline "mastodon-tl")
(autoload 'mastodon-tl--get-local-timeline "mastodon-tl")
(autoload 'mastodon-tl--get-tag-timeline "mastodon-tl")
(autoload 'mastodon-tl--goto-next-toot "mastodon-tl")
(autoload 'mastodon-tl--goto-prev-toot "mastodon-tl")
(autoload 'mastodon-tl--init-sync "mastodon-tl")
(autoload 'mastodon-tl--list-followed-tags "mastodon-tl")
(autoload 'mastodon-tl--mute-user "mastodon-tl")
(autoload 'mastodon-tl--next-tab-item "mastodon-tl")
(autoload 'mastodon-tl--poll-vote "mastodon-http")
(autoload 'mastodon-tl--previous-tab-item "mastodon-tl")
(autoload 'mastodon-tl--thread "mastodon-tl")
(autoload 'mastodon-tl--toggle-spoiler-text-in-toot "mastodon-tl")
(autoload 'mastodon-tl--unblock-user "mastodon-tl")
(autoload 'mastodon-tl--unfollow-user "mastodon-tl")
(autoload 'mastodon-tl--unmute-user "mastodon-tl")
(autoload 'mastodon-tl--report-to-mods "mastodon-tl")
(autoload 'mastodon-tl--update "mastodon-tl")
(autoload 'mastodon-toot--edit-toot-at-point "mastodon-toot")
(when (require 'lingva nil :no-error)
(autoload 'mastodon-toot--translate-toot-text "mastodon-toot"))
(autoload 'mastodon-toot--view-toot-history "mastodon-tl")
(autoload 'mastodon-views--view-follow-suggestions "mastodon-views")
(autoload 'mastodon-views--view-filters "mastodon-views")
(autoload 'mastodon-views--view-follow-requests "mastodon-views")
(autoload 'mastodon-views--view-instance-description "mastodon-views")
(autoload 'mastodon-views--view-lists "mastodon-views")
(autoload 'mastodon-views--view-scheduled-toots "mastodon-views")
(autoload 'special-mode "simple")
(defvar mastodon-notifications--map)
(defgroup mastodon nil
"Interface with Mastodon."
:prefix "mastodon-"
:group 'external)
(defcustom mastodon-instance-url "https://mastodon.social"
"Base URL for the Mastodon instance you want to be active.
For example, if your mastodon username is
\"example_user@social.instance.org\", and you want this account
to be active, the value of this variable should be
\"https://social.instance.org\".
Also for completeness, the value of `mastodon-active-user' should
be \"example_user\".
After setting these variables you should restart Emacs for these
changes to take effect."
:type 'string)
(defcustom mastodon-active-user nil
"Username of the active user.
For example, if your mastodon username is
\"example_user@social.instance.org\", and you want this account
to be active, the value of this variable should be
\"example_user\".
Also for completeness, the value of `mastodon-instance-url'
should be \"https://social.instance.org\".
After setting these variables you should restart Emacs for these
changes to take effect."
:type 'string)
(defcustom mastodon-toot-timestamp-format "%F %T"
"Format to use for timestamps.
For valid formatting options see `format-time-string`.
The default value \"%F %T\" prints ISO8601-style YYYY-mm-dd HH:MM:SS.
Use. e.g. \"%c\" for your locale's date and time format."
:type 'string)
(defvar mastodon-mode-map
(let ((map (make-sparse-keymap)))
;; navigation inside a timeline
(define-key map (kbd "n") #'mastodon-tl--goto-next-toot)
(define-key map (kbd "p") #'mastodon-tl--goto-prev-toot)
(define-key map (kbd "M-n") #'mastodon-tl--next-tab-item)
(define-key map (kbd "M-p") #'mastodon-tl--previous-tab-item)
(define-key map [?\t] #'mastodon-tl--next-tab-item)
(define-key map [backtab] #'mastodon-tl--previous-tab-item)
(define-key map [?\S-\t] #'mastodon-tl--previous-tab-item)
(define-key map [?\M-\t] #'mastodon-tl--previous-tab-item)
(define-key map (kbd "l") #'recenter-top-bottom)
;; navigation between timelines
(define-key map (kbd "#") #'mastodon-tl--get-tag-timeline)
(define-key map (kbd ":") #'mastodon-tl--list-followed-tags)
(define-key map (kbd "C-:") #'mastodon-tl--followed-tags-timeline)
(define-key map (kbd "A") #'mastodon-profile--get-toot-author)
(define-key map (kbd "F") #'mastodon-tl--get-federated-timeline)
(define-key map (kbd "H") #'mastodon-tl--get-home-timeline)
(define-key map (kbd "L") #'mastodon-tl--get-local-timeline)
(define-key map (kbd "N") #'mastodon-notifications-get)
(define-key map (kbd "@") #'mastodon-notifications--get-mentions)
(define-key map (kbd "P") #'mastodon-profile--show-user)
(define-key map (kbd "s") #'mastodon-search--search-query)
(define-key map (kbd "/") #'mastodon-switch-to-buffer)
;; quitting mastodon
(define-key map (kbd "q") #'kill-current-buffer)
(define-key map (kbd "Q") #'kill-buffer-and-window)
(define-key map (kbd "M-C-q") #'mastodon-kill-all-buffers)
;; toot actions
(define-key map (kbd "c") #'mastodon-tl--toggle-spoiler-text-in-toot)
(define-key map (kbd "b") #'mastodon-toot--toggle-boost)
(define-key map (kbd "f") #'mastodon-toot--toggle-favourite)
(define-key map (kbd "k") #'mastodon-toot--toggle-bookmark)
(define-key map (kbd "r") #'mastodon-toot--reply)
(define-key map (kbd "C") #'mastodon-toot--copy-toot-url)
(define-key map (kbd "v") #'mastodon-tl--poll-vote)
(define-key map (kbd "E") #'mastodon-toot--view-toot-edits)
(define-key map (kbd "T") #'mastodon-tl--thread)
(when (require 'lingva nil :no-error)
(define-key map (kbd "a") #'mastodon-toot--translate-toot-text))
(define-key map (kbd ",") #'mastodon-toot--list-toot-favouriters)
(define-key map (kbd ".") #'mastodon-toot--list-toot-boosters)
(define-key map (kbd ";") #'mastodon-views--view-instance-description)
;; override special mode binding
(define-key map (kbd "g") #'undefined)
(define-key map (kbd "g") #'mastodon-tl--update)
;; this is now duplicated by 'g', cd remove/use for else:
(define-key map (kbd "u") #'mastodon-tl--update)
;; own toot actions:
(define-key map (kbd "t") #'mastodon-toot)
(define-key map (kbd "d") #'mastodon-toot--delete-toot)
(define-key map (kbd "D") #'mastodon-toot--delete-and-redraft-toot)
(define-key map (kbd "i") #'mastodon-toot--pin-toot-toggle)
(define-key map (kbd "e") #'mastodon-toot--edit-toot-at-point)
;; user actions
(define-key map (kbd "W") #'mastodon-tl--follow-user)
(define-key map (kbd "C-S-W") #'mastodon-tl--unfollow-user)
(define-key map (kbd "B") #'mastodon-tl--block-user)
(define-key map (kbd "C-S-B") #'mastodon-tl--unblock-user)
(define-key map (kbd "M") #'mastodon-tl--mute-user)
(define-key map (kbd "C-S-M") #'mastodon-tl--unmute-user)
(define-key map (kbd "Z") #'mastodon-tl--report-to-mods)
;; own profile
(define-key map (kbd "O") #'mastodon-profile--my-profile)
(define-key map (kbd "U") #'mastodon-profile--update-user-profile-note)
(define-key map (kbd "V") #'mastodon-profile--view-favourites)
(define-key map (kbd "K") #'mastodon-profile--view-bookmarks)
;; minor views
(define-key map (kbd "R") #'mastodon-views--view-follow-requests)
(define-key map (kbd "S") #'mastodon-views--view-scheduled-toots)
(define-key map (kbd "I") #'mastodon-views--view-filters)
(define-key map (kbd "G") #'mastodon-views--view-follow-suggestions)
(define-key map (kbd "X") #'mastodon-views--view-lists)
map)
"Keymap for `mastodon-mode'.")
(defcustom mastodon-mode-hook nil
"Hook run when entering Mastodon mode."
:type 'hook
:options '(provide-discover-context-menu)
:group 'mastodon)
(defface mastodon-handle-face
'((t :inherit default))
"Face used for user handles in bylines.")
(defface mastodon-display-name-face
'((t :inherit warning))
"Face used for user display names.")
(defface mastodon-boosted-face
'((t :inherit success :weight bold))
"Face to indicate that a toot is boosted.")
(defface mastodon-boost-fave-face
'((t :inherit success))
"Face to indicate that you have boosted or favourited a toot.")
(defface mastodon-cw-face
'((t :inherit success))
"Face used for content warning.")
(defface mastodon-toot-docs-face
`((t :inherit font-lock-comment-face))
"Face used for documentation in toot compose buffer.
If `mastodon-tl--enable-proportional-fonts' is changed,
mastodon.el needs to be re-loaded for this to be correctly set.")
(defface mastodon-toot-docs-reply-text-face
`((t :inherit font-lock-comment-face
:family ,(face-attribute 'variable-pitch :family)))
"Face used for reply text in toot compose buffer.
See `mastodon-toot-display-orig-in-reply-buffer'.")
;;;###autoload
(defun mastodon ()
"Connect Mastodon client to `mastodon-instance-url' instance."
(interactive)
(let* ((tls (list "home"
"local"
"federated"
(concat (mastodon-auth--user-acct) "-statuses") ; own profile
"favourites"
"search"))
(buffer (or (cl-some (lambda (el)
(get-buffer (concat "*mastodon-" el "*")))
tls) ; return first buff that exists
(cl-some (lambda (x)
(when
(string-prefix-p "*mastodon-" (buffer-name x))
(get-buffer x)))
(buffer-list))))) ; catch any other masto buffer
(if buffer
(switch-to-buffer buffer)
(mastodon-tl--get-home-timeline)
(message "Loading Mastodon account %s on %s..."
(mastodon-auth--user-acct)
mastodon-instance-url))))
;;;###autoload
(defun mastodon-toot (&optional user reply-to-id reply-json)
"Update instance with new toot. Content is captured in a new buffer.
If USER is non-nil, insert after @ symbol to begin new toot.
If REPLY-TO-ID is non-nil, attach new toot to a conversation.
If REPLY-JSON is the json of the toot being replied to."
(interactive)
(mastodon-toot--compose-buffer user reply-to-id reply-json))
;;;###autoload
(defun mastodon-notifications-get (&optional type buffer-name force)
"Display NOTIFICATIONS in buffer.
Optionally only print notifications of type TYPE, a string.
BUFFER-NAME is added to \"*mastodon-\" to create the buffer name.
FORCE means do not try to update an existing buffer, but fetch
from the server and load anew."
(interactive)
(let ((buffer (if buffer-name
(concat "*mastodon-" buffer-name "*")
"*mastodon-notifications*")))
(if (and (not force)
(get-buffer buffer))
(progn (switch-to-buffer buffer)
(mastodon-tl--update))
(message "Loading your notifications...")
(mastodon-tl--init-sync
(or buffer-name "notifications")
"notifications"
'mastodon-notifications--timeline
type)
(with-current-buffer buffer
(use-local-map mastodon-notifications--map)))))
;; URL lookup: should be available even if `mastodon.el' not loaded:
;;;###autoload
(defun mastodon-url-lookup (&optional query-url)
"If a URL resembles a mastodon link, try to load in `mastodon.el'.
Does a WebFinger lookup.
URL can be arg QUERY-URL, or URL at point, or provided by the user.
If a status or account is found, load it in `mastodon.el', if
not, just browse the URL in the normal fashion."
(interactive)
(let* ((query (or query-url
(thing-at-point-url-at-point)
(mastodon-tl--property 'shr-url :no-move)
(read-string "Lookup URL: "))))
(if (not (mastodon--masto-url-p query))
;; this doesn't work as shr-browse-url doesn't take a url arg
;; and with no args it can't use our read-string query, but only
;; looks for a url at point
;; (if (equal major-mode 'mastodon-mode)
;; (shr-browse-url query) ;; keep our shr keymap
(browse-url query)
(message "Performing lookup...")
(let* ((url (format "%s/api/v2/search" mastodon-instance-url))
(params `(("q" . ,query)
("resolve" . "t"))) ; webfinger
(response (mastodon-http--get-json url params :silent)))
(cond ((not (seq-empty-p
(alist-get 'statuses response)))
(let* ((statuses (assoc 'statuses response))
(status (seq-first (cdr statuses)))
(status-id (alist-get 'id status)))
(mastodon-tl--thread status-id)))
((not (seq-empty-p
(alist-get 'accounts response)))
(let* ((accounts (assoc 'accounts response))
(account (seq-first (cdr accounts))))
(mastodon-profile--make-author-buffer account)))
(t
(browse-url query)))))))
(defun mastodon--masto-url-p (query)
"Check if QUERY resembles a fediverse URL."
;; calqued off https://github.com/tuskyapp/Tusky/blob/c8fc2418b8f5458a817bba221d025b822225e130/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt
;; thx to Conny Duck!
(let* ((uri-parsed (url-generic-parse-url query))
(query (url-filename uri-parsed)))
(save-match-data
(or (string-match "^/@[^/]+$" query)
(string-match "^/@[^/]+/[[:digit:]]+$" query)
(string-match "^/user[s]?/[[:alnum:]]+$" query)
(string-match "^/notice/[[:alnum:]]+$" query)
(string-match "^/objects/[-a-f0-9]+$" query)
(string-match "^/notes/[a-z0-9]+$" query)
(string-match "^/display/[-a-f0-9]+$" query)
(string-match "^/profile/[[:alpha:]]+$" query)
(string-match "^/p/[[:alpha:]]+/[[:digit:]]+$" query)
(string-match "^/[[:alpha:]]+$" query)
(string-match "^/u/[[:alpha:]]+$" query)))))
(defun mastodon-live-buffers ()
"Return a list of open mastodon buffers.
Calls `mastodon-tl--get-buffer-type', which see."
(cl-loop for x in (buffer-list)
when (with-current-buffer x (mastodon-tl--get-buffer-type))
collect (get-buffer x)))
(defun mastodon-kill-all-buffers ()
"Kill any and all open mastodon buffers, hopefully."
(interactive)
(let ((mastodon-buffers (mastodon-live-buffers)))
(cl-loop for x in mastodon-buffers
do (kill-buffer x))))
(defun mastodon-switch-to-buffer ()
"Switch to a live mastodon buffer."
(interactive)
(let* ((bufs (mastodon-live-buffers))
(buf-names (mapcar #'buffer-name bufs))
(choice (completing-read "Switch to mastodon buffer: "
buf-names)))
(switch-to-buffer choice)))
;;;###autoload
(add-hook 'mastodon-mode-hook (lambda ()
(when (require 'emojify nil :noerror)
(emojify-mode t)
(when mastodon-toot--enable-custom-instance-emoji
(mastodon-toot--enable-custom-emoji)))))
;;;###autoload
(add-hook 'mastodon-mode-hook #'mastodon-profile--fetch-server-account-settings)
(define-derived-mode mastodon-mode special-mode "Mastodon"
"Major mode for Mastodon, the federated microblogging network."
:group 'mastodon
(read-only-mode 1))
(provide 'mastodon)
;;; mastodon.el ends here
;;; mastodon-views.el --- Minor views functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-views.el provides minor views functions.
;; These are currently lists, follow suggestions, filters, scheduled toots,
;; follow requests, and instance descriptions.
;; It doesn't include favourites, bookmarks, preferences, trending tags, followed tags, toot edits,
;;; Code:
(require 'cl-lib)
(require 'mastodon-http)
(eval-when-compile
(require 'mastodon-tl))
(defvar mastodon-mode-map)
(defvar mastodon-tl--horiz-bar)
(defvar mastodon-tl--timeline-posts-count)
(autoload 'mastodon-mode "mastodon")
(autoload 'mastodon-tl--init "mastodon-tl")
(autoload 'mastodon-tl--init-sync "mastodon-tl")
(autoload 'mastodon-tl--field "mastodon-tl")
(autoload 'mastodon-tl--property "mastodon-tl")
(autoload 'mastodon-tl--set-face "mastodon-tl")
(autoload 'mastodon-tl--buffer-type-eq "mastodon-tl")
(autoload 'mastodon-tl--profile-buffer-p "mastodon-tl")
(autoload 'mastodon-tl--goto-next-item "mastodon-tl")
(autoload 'mastodon-tl--goto-prev-item "mastodon-tl")
(autoload 'mastodon-tl--goto-first-item "mastodon-tl")
(autoload 'mastodon-tl--do-if-toot "mastodon-tl")
(autoload 'mastodon-tl--set-buffer-spec "mastodon-tl")
(autoload 'mastodon-tl--render-text "mastodon-tl")
(autoload 'mastodon-notifications--follow-request-accept "mastodon-notifications")
(autoload 'mastodon-notifications--follow-request-reject "mastodon-notifications")
(autoload 'mastodon-auth--get-account-id "mastodon-auth")
(autoload 'mastodon-toot--iso-to-human "mastodon-toot")
(autoload 'mastodon-toot--schedule-toot "mastodon-toot")
(autoload 'mastodon-toot--compose-buffer "mastodon-toot")
(autoload 'mastodon-toot--set-toot-properties "mastodon-toot")
(autoload 'mastodon-search--propertize-user "mastodon-search")
(autoload 'mastodon-search--insert-users-propertized "mastodon-search")
(autoload 'mastodon-tl--map-alist "mastodon-tl")
(autoload 'mastodon-tl--map-alist-vals-to-alist "mastodon-tl")
;;; KEYMAPS
;; we copy `mastodon-mode-map', as then all timeline functions are
;; available. this is helpful because if a minor view is the only buffer left
;; open, calling `mastodon' will switch to it, but then we will be unable to
;; switch to timlines without closing the minor view.
;; copying the mode map however means we need to avoid/unbind/override any
;; functions that might cause interfere with the minor view.
;; this is not redundant, as while the buffer -init function calls
;; `mastodon-mode', it gets overridden in some but not all cases.
(defvar mastodon-views-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map mastodon-mode-map)
(define-key map (kbd "n") #'mastodon-tl--goto-next-item)
(define-key map (kbd "p") #'mastodon-tl--goto-prev-item)
map)
"Base keymap for minor mastodon views.")
(defvar mastodon-views--view-filters-keymap
(let ((map (make-sparse-keymap)))
(set-keymap-parent map mastodon-views-map)
(define-key map (kbd "d") #'mastodon-views--delete-filter)
(define-key map (kbd "c") #'mastodon-views--create-filter)
(define-key map (kbd "TAB") #'mastodon-tl--goto-next-item)
(define-key map (kbd "g") #'mastodon-views--view-filters)
map)
"Keymap for viewing filters.")
(defvar mastodon-views--follow-suggestions-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map mastodon-views-map)
(define-key map (kbd "g") #'mastodon-views--view-follow-suggestions)
map)
"Keymap for viewing follow suggestions.")
(defvar mastodon-views--view-lists-keymap
(let ((map (make-sparse-keymap)))
(set-keymap-parent map mastodon-views-map)
(define-key map (kbd "D") #'mastodon-views--delete-list)
(define-key map (kbd "C") #'mastodon-views--create-list)
(define-key map (kbd "A") #'mastodon-views--add-account-to-list)
(define-key map (kbd "R") #'mastodon-views--remove-account-from-list)
(define-key map (kbd "E") #'mastodon-views--edit-list)
(define-key map (kbd "g") #'mastodon-views--view-lists)
map)
"Keymap for viewing lists.")
(defvar mastodon-views--list-name-keymap
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'mastodon-views--view-timeline-list-at-point)
(define-key map (kbd "d") #'mastodon-views--delete-list-at-point)
(define-key map (kbd "a") #'mastodon-views--add-account-to-list-at-point)
(define-key map (kbd "r") #'mastodon-views--remove-account-from-list-at-point)
(define-key map (kbd "e") #'mastodon-views--edit-list-at-point)
map)
"Keymap for when point is on list name.")
(defvar mastodon-views--scheduled-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map mastodon-views-map)
(define-key map (kbd "r") #'mastodon-views--reschedule-toot)
(define-key map (kbd "c") #'mastodon-views--cancel-scheduled-toot)
(define-key map (kbd "e") #'mastodon-views--edit-scheduled-as-new)
(define-key map (kbd "RET") #'mastodon-views--edit-scheduled-as-new)
map)
"Keymap for when point is on a scheduled toot.")
(defvar mastodon-views--view-follow-requests-keymap
(let ((map (make-sparse-keymap)))
(set-keymap-parent map mastodon-views-map)
;; make reject binding match the binding in notifs view
;; 'r' is then reserved for replying, even tho it is not avail
;; in foll-reqs view
(define-key map (kbd "j") #'mastodon-notifications--follow-request-reject)
(define-key map (kbd "a") #'mastodon-notifications--follow-request-accept)
(define-key map (kbd "g") #'mastodon-views--view-follow-requests)
map)
"Keymap for viewing follow requests.")
;;; GENERAL FUNCTION
(defun mastodon-views--minor-view (view-name bindings-string insert-fun data)
"Load a minor view named VIEW-NAME.
BINDINGS-STRING is a string explaining the view's local bindings.
INSERT-FUN is the function to call to insert the view's elements.
DATA is the argument to insert-fun, usually JSON returned in a
request.
This function is used as the update-function to
`mastodon-tl--init-sync', which initializes a buffer for us and
provides the JSON data."
(erase-buffer)
(insert (mastodon-tl--set-face
(concat "\n " mastodon-tl--horiz-bar "\n "
(upcase view-name)
"\n "
mastodon-tl--horiz-bar "\n\n")
'success)
(if bindings-string
(mastodon-tl--set-face
(concat "[" bindings-string "]"
"\n\n")
'font-lock-comment-face)
""))
(if (seq-empty-p data)
(insert (propertize
(format "Looks like you have no %s for now." view-name)
'face font-lock-comment-face
'byline t
'toot-id "0")) ; so point can move here when no filters
(funcall insert-fun data)
(goto-char (point-min)))
;; (when json
;; FIXME: this seems to trigger a new request, but ideally would run.
;; (mastodon-tl--goto-next-toot))))
)
;;; LISTS
(defun mastodon-views--view-lists ()
"Show the user's lists in a new buffer."
(interactive)
(mastodon-tl--init-sync "lists"
"lists"
'mastodon-views--insert-lists)
(with-current-buffer "*mastodon-lists*"
(use-local-map mastodon-views--view-lists-keymap)))
(defun mastodon-views--insert-lists (json)
"Insert the user's lists from JSON."
(mastodon-views--minor-view
"your lists"
"C - create a list\n D - delete a list\
\n A/R - add/remove account from a list\
\n E - edit a list\n n/p - go to next/prev item"
#'mastodon-views--print-list-set
json))
(defun mastodon-views--print-list-set (lists)
"Print each account plus a separator for each list in LISTS."
(let ((lists-names
(mastodon-tl--map-alist 'title lists)))
(mapc (lambda (x)
(mastodon-views--print-list-accounts x)
(insert (propertize (concat " " mastodon-tl--horiz-bar "\n\n")
'face 'success)))
lists-names)))
(defun mastodon-views--print-list-accounts (list-name)
"Insert the accounts in list named LIST-NAME."
(let* ((id (mastodon-views--get-list-id list-name))
(accounts (mastodon-views--accounts-in-list id)))
(insert
(propertize list-name
'byline t ; so we nav here
'toot-id "0" ; so we nav here
'help-echo "RET: view list timeline, d: delete this list, \
a: add account to this list, r: remove account from this list"
'list t
'face 'link
'keymap mastodon-views--list-name-keymap
'list-name list-name
'list-id id)
(propertize
"\n\n"
'list t
'keymap mastodon-views--list-name-keymap
'list-name list-name
'list-id id)
(propertize
(mapconcat #'mastodon-search--propertize-user accounts
" ")
;; (mastodon-search--insert-users-propertized accounts)
'list t
'keymap mastodon-views--list-name-keymap
'list-name list-name
'list-id id))))
(defun mastodon-views--get-users-lists ()
"Get the list of the user's lists from the server."
(let ((url (mastodon-http--api "lists")))
(mastodon-http--get-json url)))
(defun mastodon-views--get-lists-names ()
"Return a list of the user's lists' names."
(let ((lists (mastodon-views--get-users-lists)))
(mastodon-tl--map-alist 'title lists)))
(defun mastodon-views--get-list-by-name (name)
"Return the list data for list with NAME."
(let* ((lists (mastodon-views--get-users-lists)))
(cl-loop for list in lists
if (string= (alist-get 'title list) name)
return list)))
(defun mastodon-views--get-list-id (name)
"Return id for list with NAME."
(let ((list (mastodon-views--get-list-by-name name)))
(alist-get 'id list)))
(defun mastodon-views--get-list-name (id)
"Return name of list with ID."
(let* ((url (mastodon-http--api (format "lists/%s" id)))
(response (mastodon-http--get-json url)))
(alist-get 'title response)))
(defun mastodon-views--edit-list-at-point ()
"Edit list at point."
(interactive)
(let ((id (mastodon-tl--property 'list-id :no-move)))
(mastodon-views--edit-list id)))
(defun mastodon-views--edit-list (&optional id)
"Prompt for a list and edit the name and replies policy.
If ID is provided, use that list."
(interactive)
(let* ((list-names (unless id (mastodon-views--get-lists-names)))
(name-old (if id
(mastodon-tl--property 'list-name :no-move)
(completing-read "Edit list: "
list-names)))
(id (or id (mastodon-views--get-list-id name-old)))
(name-choice (read-string "List name: " name-old))
(replies-policy (completing-read "Replies policy: " ; give this a proper name
'("followed" "list" "none")
nil t nil nil "list"))
(url (mastodon-http--api (format "lists/%s" id)))
(response (mastodon-http--put url
`(("title" . ,name-choice)
("replies_policy" . ,replies-policy)))))
(mastodon-http--triage response
(lambda ()
(with-current-buffer response
(let* ((json (mastodon-http--process-json))
(name-new (alist-get 'title json)))
(message "list %s edited to %s!" name-old name-new)))
(when (mastodon-tl--buffer-type-eq 'lists)
(mastodon-views--view-lists))))))
(defun mastodon-views--view-timeline-list-at-point ()
"View timeline of list at point."
(interactive)
(let ((list-id (mastodon-tl--property 'list-id :no-move)))
(mastodon-views--view-list-timeline list-id)))
(defun mastodon-views--view-list-timeline (&optional id)
"Prompt for a list and view its timeline.
If ID is provided, use that list."
(interactive)
(let* ((list-names (unless id (mastodon-views--get-lists-names)))
(list-name (unless id (completing-read "View list: " list-names)))
(id (or id (mastodon-views--get-list-id list-name)))
(endpoint (format "timelines/list/%s" id))
(name (mastodon-views--get-list-name id))
(buffer-name (format "list-%s" name)))
(mastodon-tl--init buffer-name endpoint
'mastodon-tl--timeline
nil
`(("limit" . ,mastodon-tl--timeline-posts-count)))))
(defun mastodon-views--create-list ()
"Create a new list.
Prompt for name and replies policy."
(interactive)
(let* ((title (read-string "New list name: "))
(replies-policy (completing-read "Replies policy: " ; give this a proper name
'("followed" "list" "none")
nil t nil nil "list")) ; default
(response (mastodon-http--post (mastodon-http--api "lists")
`(("title" . ,title)
("replies_policy" . ,replies-policy))
nil)))
(mastodon-views--list-action-triage response
(message "list %s created!" title))))
(defun mastodon-views--delete-list-at-point ()
"Delete list at point."
(interactive)
(let ((id (mastodon-tl--property 'list-id :no-move)))
(mastodon-views--delete-list id)))
(defun mastodon-views--delete-list (&optional id)
"Prompt for a list and delete it.
If ID is provided, delete that list."
(interactive)
(let* ((list-names (unless id (mastodon-views--get-lists-names)))
(name (if id
(mastodon-views--get-list-name id)
(completing-read "Delete list: "
list-names)))
(id (or id (mastodon-views--get-list-id name)))
(url (mastodon-http--api (format "lists/%s" id))))
(when (y-or-n-p (format "Delete list %s?" name))
(let ((response (mastodon-http--delete url)))
(mastodon-views--list-action-triage response
(message "list %s deleted!" name))))))
(defun mastodon-views--get-users-followings ()
"Return the list of followers of the logged in account."
(let* ((id (mastodon-auth--get-account-id))
(url (mastodon-http--api (format "accounts/%s/following" id))))
(mastodon-http--get-json url '(("limit" . "80"))))) ; max 80 accounts
(defun mastodon-views--add-account-to-list-at-point ()
"Prompt for account and add to list at point."
(interactive)
(let ((id (mastodon-tl--property 'list-id :no-move)))
(mastodon-views--add-account-to-list id)))
(defun mastodon-views--add-account-to-list (&optional id account-id handle)
"Prompt for a list and for an account, add account to list.
If ID is provided, use that list.
If ACCOUNT-ID and HANDLE are provided use them rather than prompting."
(interactive)
(let* ((list-prompt (if handle
(format "Add %s to list: " handle)
"Add account to list: "))
(list-name (if id
(mastodon-tl--property 'list-name :no-move)
(completing-read list-prompt
(mastodon-views--get-lists-names) nil t)))
(list-id (or id (mastodon-views--get-list-id list-name)))
(followings (mastodon-views--get-users-followings))
(handles (mastodon-tl--map-alist-vals-to-alist 'acct 'id followings))
(account (or handle (completing-read "Account to add: "
handles nil t)))
(account-id (or account-id (alist-get account handles nil nil 'equal)))
(url (mastodon-http--api (format "lists/%s/accounts" list-id)))
(response (mastodon-http--post url
`(("account_ids[]" . ,account-id)))))
(mastodon-views--list-action-triage
response
(message "%s added to list %s!" account list-name))))
(defun mastodon-views--add-toot-account-at-point-to-list ()
"Prompt for a list, and add the account of the toot at point to it."
(interactive)
(let* ((toot (mastodon-tl--property 'toot-json))
(account (mastodon-tl--field 'account toot))
(account-id (mastodon-tl--field 'id account))
(handle (mastodon-tl--field 'acct account)))
(mastodon-views--add-account-to-list nil account-id handle)))
(defun mastodon-views--remove-account-from-list-at-point ()
"Prompt for account and remove from list at point."
(interactive)
(let ((id (mastodon-tl--property 'list-id :no-move)))
(mastodon-views--remove-account-from-list id)))
(defun mastodon-views--remove-account-from-list (&optional id)
"Prompt for a list, select an account and remove from list.
If ID is provided, use that list."
(interactive)
(let* ((list-name (if id
(mastodon-tl--property 'list-name :no-move)
(completing-read "Remove account from list: "
(mastodon-views--get-lists-names) nil t)))
(list-id (or id (mastodon-views--get-list-id list-name)))
(accounts (mastodon-views--accounts-in-list list-id))
(handles (mastodon-tl--map-alist-vals-to-alist 'acct 'id accounts))
(account (completing-read "Account to remove: "
handles nil t))
(account-id (alist-get account handles nil nil 'equal))
(url (mastodon-http--api (format "lists/%s/accounts" list-id)))
(args (mastodon-http--build-array-params-alist "account_ids[]" `(,account-id)))
(response (mastodon-http--delete url args)))
(mastodon-views--list-action-triage
response
(message "%s removed from list %s!" account list-name))))
(defun mastodon-views--list-action-triage (response message)
"Call `mastodon-http--triage' on RESPONSE and display MESSAGE."
(mastodon-http--triage response
(lambda ()
(when (mastodon-tl--buffer-type-eq 'lists)
(mastodon-views--view-lists))
message)))
(defun mastodon-views--accounts-in-list (list-id)
"Return the JSON of the accounts in list with LIST-ID."
(let* ((url (mastodon-http--api (format "lists/%s/accounts" list-id))))
(mastodon-http--get-json url)))
;;; FOLLOW REQUESTS
(defun mastodon-views--insert-follow-requests (json)
"Insert the user's current follow requests.
JSON is the data returned by the server."
(mastodon-views--minor-view
"follow requests"
"a/j - accept/reject request at point\n n/p - go to next/prev request"
#'mastodon-views--insert-users-propertized-note
json))
(defun mastodon-views--view-follow-requests ()
"Open a new buffer displaying the user's follow requests."
(interactive)
(mastodon-tl--init-sync "follow-requests"
"follow_requests"
'mastodon-views--insert-follow-requests)
(mastodon-tl--goto-first-item)
(with-current-buffer "*mastodon-follow-requests*"
(use-local-map mastodon-views--view-follow-requests-keymap)))
;;; SCHEDULED TOOTS
(defun mastodon-views--view-scheduled-toots ()
"Show the user's scheduled toots in a new buffer."
(interactive)
(mastodon-tl--init-sync "scheduled-toots"
"scheduled_statuses"
'mastodon-views--insert-scheduled-toots)
(with-current-buffer "*mastodon-scheduled-toots*"
(use-local-map mastodon-views--scheduled-map)))
(defun mastodon-views--insert-scheduled-toots (json)
"Insert the user's scheduled toots, from JSON."
(mastodon-views--minor-view
"your scheduled toots"
"n/p - prev/next\n r - reschedule\n e/RET - edit toot\n c - cancel"
#'mastodon-views--insert-scheduled-toots-list
json))
(defun mastodon-views--insert-scheduled-toots-list (scheduleds)
"Insert scheduled toots in SCHEDULEDS."
(mapc #'mastodon-views--insert-scheduled-toot scheduleds))
(defun mastodon-views--insert-scheduled-toot (toot)
"Insert scheduled TOOT into the buffer."
(let* ((id (alist-get 'id toot))
(scheduled (alist-get 'scheduled_at toot))
(params (alist-get 'params toot))
(text (alist-get 'text params)))
(insert
(propertize (concat text
" | "
(mastodon-toot--iso-to-human scheduled))
'byline t ; so we nav here
'toot-id "0" ; so we nav here
'face 'font-lock-comment-face
'keymap mastodon-views--scheduled-map
'scheduled-json toot
'id id)
"\n")))
(defun mastodon-views--get-scheduled-toots (&optional id)
"Get the user's currently scheduled toots.
If ID, just return that toot."
(let* ((endpoint (if id
(format "scheduled_statuses/%s" id)
"scheduled_statuses"))
(url (mastodon-http--api endpoint)))
(mastodon-http--get-json url)))
(defun mastodon-views--reschedule-toot ()
"Reschedule the scheduled toot at point."
(interactive)
(let ((id (mastodon-tl--property 'id :no-move)))
(if (null id)
(message "no scheduled toot at point?")
(mastodon-toot--schedule-toot :reschedule))))
(defun mastodon-views--copy-scheduled-toot-text ()
"Copy the text of the scheduled toot at point."
(interactive)
(let* ((toot (mastodon-tl--property 'toot :no-move))
(params (alist-get 'params toot))
(text (alist-get 'text params)))
(kill-new text)))
(defun mastodon-views--cancel-scheduled-toot (&optional id no-confirm)
"Cancel the scheduled toot at point.
ID is that of the scheduled toot to cancel.
NO-CONFIRM means there is no ask or message, there is only do."
(interactive)
(let ((id (or id (mastodon-tl--property 'id :no-move))))
(if (null id)
(message "no scheduled toot at point?")
(when (or no-confirm
(y-or-n-p "Cancel scheduled toot?"))
(let* ((url (mastodon-http--api (format "scheduled_statuses/%s" id)))
(response (mastodon-http--delete url)))
(mastodon-http--triage response
(lambda ()
(mastodon-views--view-scheduled-toots)
(unless no-confirm
(message "Toot cancelled!")))))))))
(defun mastodon-views--edit-scheduled-as-new ()
"Edit scheduled status as new toot."
(interactive)
(let ((id (mastodon-tl--property 'id :no-move)))
(if (null id)
(message "no scheduled toot at point?")
(let* ((toot (mastodon-tl--property 'scheduled-json :no-move))
(scheduled (alist-get 'scheduled_at toot))
(params (alist-get 'params toot))
(text (alist-get 'text params))
(visibility (alist-get 'visibility params))
(cw (alist-get 'spoiler_text params))
(lang (alist-get 'language params))
;; (poll (alist-get 'poll params))
(reply-id (alist-get 'in_reply_to_id params)))
;; (media (alist-get 'media_attachments toot)))
(mastodon-toot--compose-buffer)
(goto-char (point-max))
(insert text)
;; adopt properties from scheduled toot:
(mastodon-toot--set-toot-properties reply-id visibility cw
lang scheduled id)))))
;;; FILTERS
(defun mastodon-views--view-filters ()
"View the user's filters in a new buffer."
(interactive)
(mastodon-tl--init-sync "filters"
"filters"
'mastodon-views--insert-filters)
(with-current-buffer "*mastodon-filters*"
(use-local-map mastodon-views--view-filters-keymap)))
(defun mastodon-views--insert-filters (json)
"Insert the user's current filters.
JSON is what is returned by by the server."
(mastodon-views--minor-view
"current filters"
"c - create filter\n d - delete filter at point\n n/p - go to next/prev filter"
#'mastodon-views--insert-filter-string-set
json))
(defun mastodon-views--insert-filter-string-set (json)
"Insert a filter string plus a blank line.
JSON is the filters data."
(mapc (lambda (x)
(mastodon-views--insert-filter-string x)
(insert "\n\n"))
json))
(defun mastodon-views--insert-filter-string (filter)
"Insert a single FILTER."
(let* ((phrase (alist-get 'phrase filter))
(contexts (alist-get 'context filter))
(id (alist-get 'id filter))
(filter-string (concat "- \"" phrase "\" filtered in: "
(mapconcat #'identity contexts ", "))))
(insert
(propertize filter-string
'toot-id id ;for goto-next-filter compat
'phrase phrase
;;'help-echo "n/p to go to next/prev filter, c to create new filter, d to delete filter at point."
;;'keymap mastodon-views--view-filters-keymap
'byline t)))) ;for goto-next-filter compat
(defun mastodon-views--create-filter ()
"Create a filter for a word.
Prompt for a context, must be a list containting at least one of \"home\",
\"notifications\", \"public\", \"thread\"."
(interactive)
(let* ((url (mastodon-http--api "filters"))
(word (read-string
(format "Word(s) to filter (%s): " (or (current-word) ""))
nil nil (or (current-word) "")))
(contexts
(if (string-empty-p word)
(error "You must select at least one word for a filter")
(completing-read-multiple
"Contexts to filter [TAB for options]: "
'("home" "notifications" "public" "thread")
nil ; no predicate
t))) ; require-match, as context is mandatory
(contexts-processed
(if (equal nil contexts)
(error "You must select at least one context for a filter")
(mapcar (lambda (x)
(cons "context[]" x))
contexts)))
(response (mastodon-http--post url (push
`("phrase" . ,word)
contexts-processed))))
(mastodon-http--triage response
(lambda ()
(message "Filter created for %s!" word)
;; reload if we are in filters view:
(when (mastodon-tl--buffer-type-eq 'filters)
(mastodon-views--view-filters))))))
(defun mastodon-views--delete-filter ()
"Delete filter at point."
(interactive)
(let* ((filter-id (mastodon-tl--property 'toot-id :no-move))
(phrase (mastodon-tl--property 'phrase :no-move))
(url (mastodon-http--api
(format "filters/%s" filter-id))))
(if (null phrase)
(error "No filter at point?")
(when (y-or-n-p (format "Delete filter %s? " phrase)))
(let ((response (mastodon-http--delete url)))
(mastodon-http--triage response (lambda ()
(mastodon-views--view-filters)
(message "Filter for \"%s\" deleted!" phrase)))))))
;;; FOLLOW SUGGESTIONS
(defun mastodon-views--view-follow-suggestions ()
"Display a buffer of suggested accounts to follow."
(interactive)
(mastodon-tl--init-sync "follow-suggestions"
"suggestions"
'mastodon-views--insert-follow-suggestions)
(with-current-buffer "*mastodon-follow-suggestions*"
(use-local-map mastodon-views--follow-suggestions-map)))
(defun mastodon-views--insert-follow-suggestions (json)
"Insert follow suggestions into buffer.
JSON is the data returned by the server."
(mastodon-views--minor-view
"suggested accounts"
nil
#'mastodon-views--insert-users-propertized-note
json))
(defun mastodon-views--insert-users-propertized-note (json)
"Insert users list into the buffer, including profile note.
JSON is the users list data."
(mastodon-search--insert-users-propertized json :note))
;;; INSTANCES
(defun mastodon-views--view-own-instance (&optional brief)
"View details of your own instance.
BRIEF means show fewer details."
(interactive)
(mastodon-views--view-instance-description :user brief))
(defun mastodon-views--view-own-instance-brief ()
"View brief details of your own instance."
(interactive)
(mastodon-views--view-instance-description :user :brief))
(defun mastodon-views--view-instance-description-brief ()
"View brief details of the instance the current post's author is on."
(interactive)
(mastodon-views--view-instance-description nil :brief))
(defun mastodon-views--get-instance-url (url username &optional instance)
"Return an instance base url from a user account URL.
USERNAME is the name to cull.
If INSTANCE is given, use that."
(cond (instance
(concat "https://" instance))
;; pleroma URL is https://instance.com/users/username
((string-suffix-p "users/" (url-basepath url))
(string-remove-suffix "/users/"
(url-basepath url)))
;; friendica is https://instance.com/profile/user
((string-suffix-p "profile/" (url-basepath url))
(string-remove-suffix "/profile/"
(url-basepath url)))
;; mastodon is https://instance.com/@user
(t
(string-remove-suffix (concat "/@" username)
url))))
(defun mastodon-views--view-instance-description (&optional user brief instance)
"View the details of the instance the current post's author is on.
USER means to show the instance details for the logged in user.
BRIEF means to show fewer details.
INSTANCE is an instance domain name."
(interactive)
(if user
(let ((response (mastodon-http--get-json
(mastodon-http--api "instance")
nil ; params
nil ; silent
:vector)))
(mastodon-views--instance-response-fun response brief instance))
(mastodon-tl--do-if-toot
(let* ((toot (if (mastodon-tl--profile-buffer-p)
;; we may be on profile description itself:
(or (mastodon-tl--property 'profile-json)
;; or on profile account listings, which use toot-json:
;; or just toots:
(mastodon-tl--property 'toot-json))
;; normal timeline/account listing:
(mastodon-tl--property 'toot-json)))
(reblog (alist-get 'reblog toot))
(account (or (alist-get 'account reblog)
(alist-get 'account toot)
toot)) ; else `toot' is already an account listing.
;; we can't use --profile-buffer-p as our test here because we may
;; be looking at toots/boosts/users in a profile buffer.
;; profile-json works as a defacto test for if point is on the
;; profile details at the top of a profile buffer.
(url (if (and (mastodon-tl--profile-buffer-p)
;; only call this in profile buffers:
(mastodon-tl--property 'profile-json))
(alist-get 'url toot) ; profile description
(alist-get 'url account)))
(username (if (and (mastodon-tl--profile-buffer-p)
;; only call this in profile buffers:
(mastodon-tl--property 'profile-json))
(alist-get 'username toot) ;; profile
(alist-get 'username account)))
(instance (mastodon-views--get-instance-url url username instance))
(response (mastodon-http--get-json
(if user
(mastodon-http--api "instance")
(concat instance "/api/v1/instance"))
nil ; params
nil ; silent
:vector)))
(mastodon-views--instance-response-fun response brief instance)))))
(defun mastodon-views--instance-response-fun (response brief instance)
"Display instance description RESPONSE in a new buffer.
BRIEF means to show fewer details.
INSTANCE is the instance were are working with."
(when response
(let* ((domain (url-file-nondirectory instance))
(buf (get-buffer-create
(format "*mastodon-instance-%s*" domain))))
(with-mastodon-buffer buf #'special-mode :other-window
(when brief
(setq response
(list (assoc 'uri response)
(assoc 'title response)
(assoc 'short_description response)
(assoc 'email response)
(cons 'contact_account
(list
(assoc 'username
(assoc 'contact_account response))))
(assoc 'rules response)
(assoc 'stats response))))
(mastodon-views--print-json-keys response)
(mastodon-tl--set-buffer-spec (buffer-name buf) "instance" nil)
(goto-char (point-min))))))
(defun mastodon-views--format-key (el pad)
"Format a key of element EL, a cons, with PAD padding."
(format (concat "%-"
(number-to-string pad)
"s: ")
(propertize
(prin1-to-string (car el))
'face '(:underline t))))
(defun mastodon-views--print-json-keys (response &optional ind)
"Print the JSON keys and values in RESPONSE.
IND is the optional indentation level to print at."
(let* ((cars (mapcar
(lambda (x) (symbol-name (car x)))
response))
(pad (1+ (cl-reduce #'max (mapcar #'length cars)))))
(while response
(let ((el (pop response)))
(cond
;; vector of alists (fields, instance rules):
((and (vectorp (cdr el))
(not (seq-empty-p (cdr el)))
(consp (seq-elt (cdr el) 0)))
(insert
(mastodon-views--format-key el pad)
"\n\n")
(seq-do #'mastodon-views--print-instance-rules-or-fields (cdr el))
(insert "\n"))
;; vector of strings (media types):
((and (vectorp (cdr el))
(not (seq-empty-p (cdr el)))
(< 1 (seq-length (cdr el)))
(stringp (seq-elt (cdr el) 0)))
(when ind (indent-to ind))
(insert
(mastodon-views--format-key el pad)
"\n"
(seq-mapcat
(lambda (x) (concat x ", "))
(cdr el) 'string)
"\n\n"))
;; basic nesting:
((consp (cdr el))
(when ind (indent-to ind))
(insert
(mastodon-views--format-key el pad)
"\n\n")
(mastodon-views--print-json-keys
(cdr el) (if ind (+ ind 4) 4)))
(t
;; basic handling of raw booleans:
(let ((val (cond ((equal (cdr el) ':json-false)
"no")
((equal (cdr el) 't)
"yes")
(t
(cdr el)))))
(when ind (indent-to ind))
(insert (mastodon-views--format-key el pad)
" "
(mastodon-views--newline-if-long (cdr el))
;; only send strings straight to --render-text
;; this makes hyperlinks work:
(if (not (stringp val))
(mastodon-tl--render-text
(prin1-to-string val))
(mastodon-tl--render-text val))
"\n"))))))))
(defun mastodon-views--print-instance-rules-or-fields (alist)
"Print ALIST of instance rules or contact account or emoji fields."
(let ((key (or (alist-get 'id alist)
(alist-get 'name alist)
(alist-get 'shortcode alist)))
(value (or (alist-get 'text alist)
(alist-get 'value alist)
(alist-get 'url alist))))
(indent-to 4)
(insert
(format "%-5s: "
(propertize key
'face '(:underline t)))
(mastodon-views--newline-if-long value)
(format "%s" (mastodon-tl--render-text
value))
"\n")))
(defun mastodon-views--newline-if-long (el)
"Return a newline string if the cdr of EL is over 50 characters long."
(let ((rend (if (stringp el) (mastodon-tl--render-text el) el)))
(if (and (sequencep rend)
(< 50 (length rend)))
"\n"
"")))
(provide 'mastodon-views)
;;; mastodon-views.el ends here
;;; mastodon-toot.el --- Minor mode for sending Mastodon toots -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-toot.el supports POSTing status data to Mastodon.
;;; Code:
(eval-when-compile (require 'subr-x))
(require 'emojify nil :noerror)
(declare-function emojify-insert-emoji "emojify")
(declare-function emojify-set-emoji-data "emojify")
(defvar emojify-emojis-dir)
(defvar emojify-user-emojis)
(require 'cl-lib)
(require 'persist)
(require 'mastodon-iso)
(require 'facemenu)
(require 'text-property-search)
(eval-when-compile
(require 'mastodon-tl))
(defvar mastodon-instance-url)
(defvar mastodon-tl--buffer-spec)
(defvar mastodon-tl--enable-proportional-fonts)
(defvar mastodon-profile-account-settings)
(autoload 'iso8601-parse "iso8601")
(autoload 'mastodon-auth--user-acct "mastodon-auth")
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--build-array-params-alist "mastodon-http")
(autoload 'mastodon-http--delete "mastodon-http")
(autoload 'mastodon-http--get-json "mastodon-http")
(autoload 'mastodon-http--get-json-async "mastodon-http")
(autoload 'mastodon-http--post "mastodon-http")
(autoload 'mastodon-http--post-media-attachment "mastodon-http")
(autoload 'mastodon-http--process-json "mastodon-http")
(autoload 'mastodon-http--put "mastodon-http")
(autoload 'mastodon-http--read-file-as-string "mastodon-http")
(autoload 'mastodon-http--triage "mastodon-http")
(autoload 'mastodon-profile--fetch-server-account-settings "mastodon-profile")
(autoload 'mastodon-profile--fetch-server-account-settings-maybe "mastodon-profile")
(autoload 'mastodon-profile--get-source-pref "mastodon-profile")
(autoload 'mastodon-profile--show-user "mastodon-profile")
(autoload 'mastodon-profile--update-preference "mastodon-profile")
(autoload 'mastodon-search--search-accounts-query "mastodon-search")
(autoload 'mastodon-search--search-tags-query "mastodon-search")
(autoload 'mastodon-tl--as-string "mastodon-tl")
(autoload 'mastodon-tl--buffer-type-eq "mastodon-tl")
(autoload 'mastodon-tl--clean-tabs-and-nl "mastodon-tl")
(autoload 'mastodon-tl--do-if-toot-strict "mastodon-tl")
(autoload 'mastodon-tl--field "mastodon-tl")
(autoload 'mastodon-tl--find-property-range "mastodon-tl")
(autoload 'mastodon-tl--find-property-range "mastodon-tl")
(autoload 'mastodon-tl--goto-next-toot "mastodon-tl")
(autoload 'mastodon-tl--map-alist "mastodon-tl")
(autoload 'mastodon-tl--property "mastodon-tl")
(autoload 'mastodon-tl--reload-timeline-or-profile "mastodon-tl")
(autoload 'mastodon-tl--render-text "mastodon-tl")
(autoload 'mastodon-tl--set-buffer-spec "mastodon-tl")
(autoload 'mastodon-tl--symbol "mastodon-tl")
(autoload 'mastodon-tl--toot-id "mastodon-tl")
(autoload 'mastodon-toot "mastodon")
(autoload 'mastodon-views--cancel-scheduled-toot "mastodon-views")
(autoload 'mastodon-views--view-scheduled-toots "mastodon-views")
(autoload 'org-read-date "org")
;; for mastodon-toot--translate-toot-text
(autoload 'mastodon-tl--content "mastodon-tl")
(when (require 'lingva nil :no-error)
(declare-function lingva-translate "lingva"))
(defgroup mastodon-toot nil
"Tooting in Mastodon."
:prefix "mastodon-toot-"
:group 'mastodon)
(defcustom mastodon-toot--default-media-directory "~/"
"The default directory when prompting for a media file to upload."
:type 'string)
(defcustom mastodon-toot--attachment-height 80
"Height of the attached images preview in the toot draft buffer."
:type 'integer)
(defcustom mastodon-toot--enable-completion t
"Whether to enable completion of mentions and hashtags.
Used for completion in toot compose buffer."
:type 'boolean)
(defcustom mastodon-toot--use-company-for-completion nil
"Whether to enable company for completion.
When non-nil, `company-mode' is enabled in the toot compose
buffer, and mastodon completion backends are added to
`company-capf'.
You need to install company yourself to use this."
:type 'boolean)
(defcustom mastodon-toot--completion-style-for-mentions "all"
"The company completion style to use for mentions."
:type '(choice
(const :tag "off" nil)
(const :tag "following only" "following")
(const :tag "all users" "all")))
(defcustom mastodon-toot-display-orig-in-reply-buffer nil
"Display a copy of the toot replied to in the compose buffer."
:type 'boolean)
(defcustom mastodon-toot-orig-in-reply-length 191
;; three lines of divider width: (- (* 3 67) (length " Reply to: "))
"Length to crop toot replied to in the compose buffer to."
:type 'integer)
(defcustom mastodon-toot--default-reply-visibility "public"
"Default visibility settings when replying.
If the original toot visibility is different we use the more restricted one."
:type '(choice
(const :tag "public" "public")
(const :tag "unlisted" "unlisted")
(const :tag "followers only" "private")
(const :tag "direct" "direct")))
(defcustom mastodon-toot--enable-custom-instance-emoji nil
"Whether to enable your instance's custom emoji by default."
:type 'boolean)
(defcustom mastodon-toot--proportional-fonts-compose nil
"Nonnil to enable using proportional fonts in the compose buffer.
By default fixed width fonts are used."
:type '(boolean :tag "Enable using proportional rather than fixed \
width fonts"))
(defvar-local mastodon-toot--content-warning nil
"A flag whether the toot should be marked with a content warning.")
(defvar-local mastodon-toot--content-warning-from-reply-or-redraft nil
"The content warning of the toot being replied to.")
(defvar-local mastodon-toot--content-nsfw nil
"A flag indicating whether the toot should be marked as NSFW.")
(defvar mastodon-toot-visibility-list
'(direct private unlisted public)
"A list of the available toot visibility settings.")
(defvar-local mastodon-toot--visibility nil
"A string indicating the visibility of the toot being composed.
Valid values are \"direct\", \"private\" (followers-only),
\"unlisted\", and \"public\".
This is determined by the account setting on the server. To
change the setting on the server, see
`mastodon-toot--set-default-visibility'.")
(defvar-local mastodon-toot--media-attachments nil
"A list of the media attachments of the toot being composed.")
(defvar-local mastodon-toot--media-attachment-ids nil
"A list of any media attachment ids of the toot being composed.")
(defvar-local mastodon-toot-poll nil
"A list of poll options for the toot being composed.")
(defvar-local mastodon-toot--language nil
"The language of the toot being composed, in ISO 639 (two-letter).")
(defvar-local mastodon-toot--scheduled-for nil
"An ISO 8601 timestamp that specifying when the post should be published.
Should be at least 5 minutes into the future.")
(defvar-local mastodon-toot--scheduled-id nil
"The id of the scheduled post that we are now editing.")
(defvar-local mastodon-toot--reply-to-id nil
"Buffer-local variable to hold the id of the toot being replied to.")
(defvar-local mastodon-toot--edit-toot-id nil
"The id of the toot being edited.")
(defvar-local mastodon-toot-previous-window-config nil
"A list of window configuration prior to composing a toot.
Takes its form from `window-configuration-to-register'.")
(defvar mastodon-toot--max-toot-chars nil
"The maximum allowed characters count for a single toot.")
(defvar-local mastodon-toot-completions nil
"The data of completion candidates for the current completion at point.")
(defvar mastodon-toot-current-toot-text nil
"The text of the toot being composed.")
(persist-defvar mastodon-toot-draft-toots-list nil
"A list of toots that have been saved as drafts.
For the moment we just put all composed toots in here, as we want
to also capture toots that are 'sent' but that don't successfully
send.")
(defvar mastodon-toot-handle-regex
(rx (| (any ?\( "\n" "\t "" ") bol) ; preceding things
(group-n 2 (+ ?@ (* (any ?- ?_ ?. "A-Z" "a-z" "0-9" ))) ; handle
(? ?@ (* (not (any "\n" "\t" " "))))) ; optional domain
(| "'" word-boundary))) ; boundary or possessive
(defvar mastodon-toot-tag-regex
(rx (| (any ?\( "\n" "\t" " ") bol)
(group-n 2 ?# (+ (any "A-Z" "a-z" "0-9")))
(| "'" word-boundary))) ; boundary or possessive
(defvar mastodon-toot-url-regex
;; adapted from ffap-url-regexp
(concat
"\\(?2:\\(news\\(post\\)?:\\|mailto:\\|file:\\|\\(ftp\\|https?\\|telnet\\|gopher\\|www\\|wais\\)://\\)" ; uri prefix
"[^ \n\t]*\\)" ; any old thing that's, i.e. we allow invalid/unwise chars
"\\b")) ; boundary
(defvar mastodon-toot-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c C-c") #'mastodon-toot--send)
(define-key map (kbd "C-c C-k") #'mastodon-toot--cancel)
(define-key map (kbd "C-c C-w") #'mastodon-toot--toggle-warning)
(define-key map (kbd "C-c C-n") #'mastodon-toot--toggle-nsfw)
(define-key map (kbd "C-c C-v") #'mastodon-toot--change-visibility)
(when (require 'emojify nil :noerror)
(define-key map (kbd "C-c C-e") #'mastodon-toot--insert-emoji))
(define-key map (kbd "C-c C-a") #'mastodon-toot--attach-media)
(define-key map (kbd "C-c !") #'mastodon-toot--clear-all-attachments)
(define-key map (kbd "C-c C-p") #'mastodon-toot--create-poll)
(define-key map (kbd "C-c C-l") #'mastodon-toot--set-toot-language)
(define-key map (kbd "C-c C-s") #'mastodon-toot--schedule-toot)
map)
"Keymap for `mastodon-toot'.")
(defun mastodon-toot--set-default-visibility ()
"Set the default visibility for toots on the server."
(interactive)
(let ((vis (completing-read "Set default visibility to:"
mastodon-toot-visibility-list
nil t)))
(mastodon-profile--update-preference "privacy" vis :source)))
(defun mastodon-toot--get-max-toot-chars (&optional no-toot)
"Fetch max_toot_chars from `mastodon-instance-url' asynchronously.
NO-TOOT means we are not calling from a toot buffer."
(mastodon-http--get-json-async
(mastodon-http--api "instance")
nil
'mastodon-toot--get-max-toot-chars-callback no-toot))
(defun mastodon-toot--get-max-toot-chars-callback (json-response
&optional no-toot)
"Set max_toot_chars returned in JSON-RESPONSE and display in new toot buffer.
NO-TOOT means we are not calling from a toot buffer."
(let ((max-chars
(or
(alist-get 'max_toot_chars json-response)
;; some servers have this instead:
(alist-get 'max_characters
(alist-get 'statuses
(alist-get 'configuration
json-response))))))
(setq mastodon-toot--max-toot-chars max-chars)
(unless no-toot
(with-current-buffer "*new toot*"
(mastodon-toot--update-status-fields)))))
(defun mastodon-toot--action-success (marker byline-region remove)
"Insert/remove the text MARKER with `success' face in byline.
BYLINE-REGION is a cons of start and end pos of the byline to be
modified.
Remove MARKER if REMOVE is non-nil, otherwise add it."
(let ((inhibit-read-only t)
(bol (car byline-region))
(eol (cdr byline-region))
(at-byline-p (eq (mastodon-tl--property 'byline :no-move) t)))
(save-excursion
(when remove
(goto-char bol)
(beginning-of-line) ;; The marker is not part of the byline
(if (search-forward (format "(%s) " marker) eol t)
(replace-match "")
(message "Oops: could not find marker '(%s)'" marker)))
(unless remove
(goto-char bol)
(insert (format "(%s) "
(propertize marker 'face 'success)))))
(when at-byline-p
;; leave point after the marker:
(unless remove
;; if point is inside the byline, back up first so
;; we don't move to the following toot:
(beginning-of-line)
(forward-line -1)
(mastodon-tl--goto-next-toot)))))
(defun mastodon-toot--action (action callback)
"Take ACTION on toot at point, then execute CALLBACK.
Makes a POST request to the server. Used for favouriting,
boosting, or bookmarking toots."
(let* ((id (mastodon-tl--property 'base-toot-id))
(url (mastodon-http--api (concat "statuses/"
(mastodon-tl--as-string id)
"/"
action))))
(let ((response (mastodon-http--post url)))
(mastodon-http--triage response callback))))
(defun mastodon-toot--toggle-boost-or-favourite (type)
"Toggle boost or favourite of toot at `point'.
TYPE is a symbol, either `favourite' or `boost.'"
(mastodon-tl--do-if-toot-strict
(let* ((boost-p (equal type 'boost))
(has-id (mastodon-tl--property 'base-toot-id))
(byline-region (when has-id
(mastodon-tl--find-property-range 'byline (point))))
(id (when byline-region
(mastodon-tl--as-string (mastodon-tl--property 'base-toot-id))))
(boosted (when byline-region
(get-text-property (car byline-region) 'boosted-p)))
(faved (when byline-region
(get-text-property (car byline-region) 'favourited-p)))
(action (if boost-p
(if boosted "unreblog" "reblog")
(if faved "unfavourite" "favourite")))
(msg (if boosted "unboosted" "boosted"))
(action-string (if boost-p "boost" "favourite"))
(remove (if boost-p (when boosted t) (when faved t)))
(toot-type (alist-get 'type (mastodon-tl--property 'toot-json)))
(visibility (mastodon-tl--field 'visibility
(mastodon-tl--property 'toot-json))))
(if byline-region
(if (and (or (equal visibility "direct")
(equal visibility "private"))
boost-p)
(message "You cant boost posts with visibility: %s" visibility)
(cond ;; actually there's nothing wrong with faving/boosting own toots!
;;((mastodon-toot--own-toot-p (mastodon-tl--property 'toot-json))
;;(error "You can't %s your own toots" action-string))
;; & nothing wrong with faving/boosting own toots from notifs:
;; this boosts/faves the base toot, not the notif status
((and (equal "reblog" toot-type)
(not (mastodon-tl--buffer-type-eq 'notifications)))
(error "You can't %s boosts" action-string))
((and (equal "favourite" toot-type)
(not (mastodon-tl--buffer-type-eq 'notifications)))
(error "You can't %s favourites" action-string))
((and (equal "private" visibility)
(equal type 'boost))
(error "You can't boost private toots"))
(t
(mastodon-toot--action
action
(lambda ()
(let ((inhibit-read-only t))
(add-text-properties (car byline-region)
(cdr byline-region)
(if boost-p
(list 'boosted-p (not boosted))
(list 'favourited-p (not faved))))
(mastodon-toot--update-stats-on-action type remove)
(mastodon-toot--action-success
(if boost-p
(mastodon-tl--symbol 'boost)
(mastodon-tl--symbol 'favourite))
byline-region remove))
(message (format "%s #%s" (if boost-p msg action) id)))))))
(message (format "Nothing to %s here?!?" action-string))))))
(defun mastodon-toot--inc-or-dec (count subtract)
"If SUBTRACT, decrement COUNT, else increment."
(if subtract
(1- count)
(1+ count)))
(defun mastodon-toot--update-stats-on-action (action &optional subtract)
"Increment the toot stats display upon ACTION.
ACTION is a symbol, either `favourite' or `boost'.
SUBTRACT means we are un-favouriting or unboosting, so we decrement."
(let* ((count-prop (if (eq action 'favourite)
'favourites-count
'boosts-count))
(count-prop-range (mastodon-tl--find-property-range count-prop (point)))
(count (get-text-property (car count-prop-range) count-prop))
(inhibit-read-only 1))
;; TODO another way to implement this would be to async fetch counts again
;; and re-display from count-properties
(add-text-properties
(car count-prop-range)
(cdr count-prop-range)
(list 'display ; update the display prop:
(number-to-string
(mastodon-toot--inc-or-dec count subtract))
;; update the count prop
;; we rely on this for any subsequent actions:
count-prop
(mastodon-toot--inc-or-dec count subtract)))))
(defun mastodon-toot--toggle-boost ()
"Boost/unboost toot at `point'."
(interactive)
(mastodon-toot--toggle-boost-or-favourite 'boost))
(defun mastodon-toot--toggle-favourite ()
"Favourite/unfavourite toot at `point'."
(interactive)
(mastodon-toot--toggle-boost-or-favourite 'favourite))
;; TODO maybe refactor into boost/fave fun
(defun mastodon-toot--toggle-bookmark ()
"Bookmark or unbookmark toot at point."
(interactive)
(mastodon-tl--do-if-toot-strict
(let* ( ;(toot (mastodon-tl--property 'toot-json))
(id (mastodon-tl--property 'base-toot-id))
;; (mastodon-tl--as-string (mastodon-tl--toot-id toot)))
(bookmarked-p (mastodon-tl--property 'bookmarked-p))
(prompt (if bookmarked-p
(format "Toot already bookmarked. Remove? ")
(format "Bookmark this toot? ")))
(byline-region
(when id
(mastodon-tl--find-property-range 'byline (point))))
(action (if bookmarked-p "unbookmark" "bookmark"))
(bookmark-str (mastodon-tl--symbol 'bookmark))
(message (if bookmarked-p
"Bookmark removed!"
"Toot bookmarked!"))
(remove (when bookmarked-p t)))
(if byline-region
(when (y-or-n-p prompt)
(mastodon-toot--action
action
(lambda ()
(let ((inhibit-read-only t))
(add-text-properties (car byline-region)
(cdr byline-region)
(list 'bookmarked-p (not bookmarked-p))))
(mastodon-toot--action-success
bookmark-str
byline-region remove)
(message (format "%s #%s" message id)))))
(message (format "Nothing to %s here?!?" action))))))
(defun mastodon-toot--list-toot-boosters ()
"List the boosters of toot at point."
(interactive)
(mastodon-toot--list-toot-boosters-or-favers))
(defun mastodon-toot--list-toot-favouriters ()
"List the favouriters of toot at point."
(interactive)
(mastodon-toot--list-toot-boosters-or-favers :favourite))
(defun mastodon-toot--list-toot-boosters-or-favers (&optional favourite)
"List the favouriters or boosters of toot at point.
With FAVOURITE, list favouriters, else list boosters."
(mastodon-tl--do-if-toot-strict
(let* ((base-toot (mastodon-tl--property 'base-toot-id))
(endpoint (if favourite "favourited_by" "reblogged_by"))
(url (mastodon-http--api
(format "statuses/%s/%s" base-toot endpoint)))
(params '(("limit" . "80")))
(json (mastodon-http--get-json url params)))
(if (eq (caar json) 'error)
(error "%s (Status does not exist or is private)"
(alist-get 'error json))
(let ((handles (mastodon-tl--map-alist 'acct json))
(type-string (if favourite "Favouriters" "Boosters")))
(if (not handles)
(error "Looks like this toot has no %s" type-string)
(let ((choice
(completing-read
(format "%s (enter to view profile): " type-string)
handles
nil
t)))
(mastodon-profile--show-user choice))))))))
(defun mastodon-toot--copy-toot-url ()
"Copy URL of toot at point.
If the toot is a fave/boost notification, copy the URL of the
base toot."
(interactive)
(let* ((toot (or (mastodon-tl--property 'base-toot)
(mastodon-tl--property 'toot-json)))
(url (if (mastodon-tl--field 'reblog toot)
(alist-get 'url (alist-get 'reblog toot))
(alist-get 'url toot))))
(kill-new url)
(message "Toot URL copied to the clipboard.")))
(defun mastodon-toot--copy-toot-text ()
"Copy text of toot at point.
If the toot is a fave/boost notification, copy the text of the
base toot."
(interactive)
(let* ((toot (or (mastodon-tl--property 'base-toot)
(mastodon-tl--property 'toot-json))))
(kill-new (mastodon-tl--content toot))
(message "Toot content copied to the clipboard.")))
;; (when (require 'lingva nil :no-error)
(defun mastodon-toot--translate-toot-text ()
"Translate text of toot at point.
Uses `lingva.el'."
(interactive)
(if (not (require 'lingva nil :no-error))
(message "Looks like you need to install lingva.el first.")
(if mastodon-tl--buffer-spec
(let ((toot (mastodon-tl--property 'toot-json)))
(if toot
(lingva-translate nil
(mastodon-tl--content toot)
(when mastodon-tl--enable-proportional-fonts
t))
(message "No toot to translate?")))
(message "No mastodon buffer?"))))
(defun mastodon-toot--own-toot-p (toot)
"Check if TOOT is user's own, e.g. for deleting it."
(and (not (alist-get 'reblog toot))
(equal (alist-get 'acct (alist-get 'account toot))
(mastodon-auth--user-acct))))
(defun mastodon-toot--pin-toot-toggle ()
"Pin or unpin user's toot at point."
(interactive)
(let* ((toot (or (mastodon-tl--property 'base-toot) ;fave/boost notifs
(mastodon-tl--property 'toot-json)))
(pinnable-p (mastodon-toot--own-toot-p toot))
(pinned-p (equal (alist-get 'pinned toot) t))
(action (if pinned-p "unpin" "pin"))
(msg (if pinned-p "unpinned" "pinned"))
(msg-y-or-n (if pinned-p "Unpin" "Pin")))
(if (not pinnable-p)
(message "You can only pin your own toots.")
(when (y-or-n-p (format "%s this toot? " msg-y-or-n))
(mastodon-toot--action action
(lambda ()
(when mastodon-tl--buffer-spec
(mastodon-tl--reload-timeline-or-profile))
(message "Toot %s!" msg)))))))
(defun mastodon-toot--delete-toot ()
"Delete user's toot at point synchronously."
(interactive)
(mastodon-toot--delete-and-redraft-toot t))
;; TODO: handle media/poll for redrafting toots
(defun mastodon-toot--delete-and-redraft-toot (&optional no-redraft)
"Delete and redraft user's toot at point synchronously.
NO-REDRAFT means delete toot only."
(interactive)
(let* ((toot (or (mastodon-tl--property 'base-toot) ;fave/boost notifs
(mastodon-tl--property 'toot-json)))
(id (mastodon-tl--as-string (mastodon-tl--toot-id toot)))
(url (mastodon-http--api (format "statuses/%s" id)))
(toot-cw (alist-get 'spoiler_text toot))
(toot-visibility (alist-get 'visibility toot))
(reply-id (alist-get 'in_reply_to_id toot))
(pos (point)))
(if (not (mastodon-toot--own-toot-p toot))
(message "You can only delete (and redraft) your own toots.")
(when (y-or-n-p (if no-redraft
(format "Delete this toot? ")
(format "Delete and redraft this toot? ")))
(let* ((response (mastodon-http--delete url)))
(mastodon-http--triage
response
(lambda ()
(if no-redraft
(progn
(when mastodon-tl--buffer-spec
(mastodon-tl--reload-timeline-or-profile pos))
(message "Toot deleted!"))
(mastodon-toot--redraft response
reply-id
toot-visibility
toot-cw)))))))))
(defun mastodon-toot--set-cw (&optional cw)
"Set content warning to CW if it is non-nil."
(unless (or (null cw) ; cw is nil for `mastodon-tl--dm-user'
(string-empty-p cw))
(setq mastodon-toot--content-warning t)
(setq mastodon-toot--content-warning-from-reply-or-redraft cw)))
(defun mastodon-toot--redraft (response &optional reply-id toot-visibility toot-cw)
"Opens a new toot compose buffer using values from RESPONSE buffer.
REPLY-ID, TOOT-VISIBILITY, and TOOT-CW of deleted toot are preseved."
(with-current-buffer response
(let* ((json-response (mastodon-http--process-json))
(content (alist-get 'text json-response)))
(mastodon-toot--compose-buffer)
(goto-char (point-max))
(insert content)
;; adopt reply-to-id, visibility and CW from deleted toot:
(mastodon-toot--set-toot-properties
reply-id toot-visibility toot-cw
;; TODO set new lang/scheduled props here
nil))))
(defun mastodon-toot--set-toot-properties (reply-id visibility cw lang
&optional scheduled
scheduled-id)
"Set the toot properties for the current redrafted or edited toot.
REPLY-ID, VISIBILITY, CW, SCHEDULED, and LANG are the properties to set."
(when reply-id
(setq mastodon-toot--reply-to-id reply-id))
(setq mastodon-toot--visibility visibility)
(setq mastodon-toot--scheduled-for scheduled)
(setq mastodon-toot--scheduled-id scheduled-id)
(when (not (string-empty-p lang))
(setq mastodon-toot--language lang))
(mastodon-toot--set-cw cw)
(mastodon-toot--update-status-fields))
(defun mastodon-toot--kill (&optional cancel)
"Kill `mastodon-toot-mode' buffer and window.
CANCEL means the toot was not sent, so we save the toot text as a draft."
(let ((prev-window-config mastodon-toot-previous-window-config))
(unless (eq mastodon-toot-current-toot-text nil)
(when cancel
(cl-pushnew mastodon-toot-current-toot-text
mastodon-toot-draft-toots-list :test 'equal)))
;; prevent some weird bug when cancelling a non-empty toot:
(delete #'mastodon-toot--save-toot-text after-change-functions)
(kill-buffer-and-window)
(mastodon-toot--restore-previous-window-config prev-window-config)))
(defun mastodon-toot--cancel ()
"Kill new-toot buffer/window. Does not POST content to Mastodon.
If toot is not empty, prompt to save text as a draft."
(interactive)
(if (mastodon-toot--empty-p)
(mastodon-toot--kill)
(when (y-or-n-p "Save draft toot?")
(mastodon-toot--save-draft))
(mastodon-toot--kill)))
(defun mastodon-toot--save-draft ()
"Save the current compose toot text as a draft.
Pushes `mastodon-toot-current-toot-text' to
`mastodon-toot-draft-toots-list'."
(interactive)
(unless (eq mastodon-toot-current-toot-text nil)
(cl-pushnew mastodon-toot-current-toot-text
mastodon-toot-draft-toots-list :test 'equal)
(message "Draft saved!")))
(defun mastodon-toot--empty-p (&optional text-only)
"Return t if toot has no text, attachments, or polls.
TEXT-ONLY means don't check for attachments or polls."
(and (if text-only
t
(not mastodon-toot--media-attachments)
(not mastodon-toot-poll))
(string-empty-p (mastodon-tl--clean-tabs-and-nl
(mastodon-toot--remove-docs)))))
(defalias 'mastodon-toot--insert-emoji
'emojify-insert-emoji
"Prompt to insert an emoji.")
(defun mastodon-toot--download-custom-emoji ()
"Download `mastodon-instance-url's custom emoji.
Emoji images are stored in a subdir of `emojify-emojis-dir'.
To use the downloaded emoji, run `mastodon-toot--enable-custom-emoji'."
(interactive)
(let ((custom-emoji (mastodon-http--get-json
(mastodon-http--api "custom_emojis")))
(mastodon-custom-emoji-dir (file-name-as-directory
(concat (file-name-as-directory
(expand-file-name
emojify-emojis-dir))
"mastodon-custom-emojis"))))
(if (not (file-directory-p emojify-emojis-dir))
(message "Looks like you need to set up emojify first.")
(unless (file-directory-p mastodon-custom-emoji-dir)
(make-directory mastodon-custom-emoji-dir nil)) ; no add parent
(mapc (lambda (x)
(let ((url (alist-get 'url x))
(shortcode (alist-get 'shortcode x)))
;; skip anything that contains unexpected characters
(when (and url shortcode
(string-match-p "^[a-zA-Z0-9-_]+$" shortcode)
(string-match-p "^[a-zA-Z]+$" (file-name-extension url)))
(url-copy-file url
(concat
mastodon-custom-emoji-dir
shortcode
"."
(file-name-extension url))
t))))
custom-emoji)
(message "Custom emoji for %s downloaded to %s"
mastodon-instance-url
mastodon-custom-emoji-dir))))
(defun mastodon-toot--collect-custom-emoji ()
"Return a list of `mastodon-instance-url's custom emoji.
The list is formatted for `emojify-user-emojis', which see."
(let* ((mastodon-custom-emojis-dir (concat (expand-file-name
emojify-emojis-dir)
"/mastodon-custom-emojis/"))
(custom-emoji-files (directory-files mastodon-custom-emojis-dir
nil ; not full path
"^[^.]")) ; no dot files
(mastodon-emojify-user-emojis))
(mapc (lambda (x)
(push
`(,(concat ":"
(file-name-base x) ":")
. (("name" . ,(file-name-base x))
("image" . ,(concat mastodon-custom-emojis-dir x))
("style" . "github")))
mastodon-emojify-user-emojis))
custom-emoji-files)
(reverse mastodon-emojify-user-emojis)))
(defun mastodon-toot--enable-custom-emoji ()
"Add `mastodon-instance-url's custom emoji to `emojify'.
Custom emoji must first be downloaded with
`mastodon-toot--download-custom-emoji'. Custom emoji are appended
to `emojify-user-emojis', and the emoji data is updated."
(interactive)
(unless (file-exists-p (concat (expand-file-name
emojify-emojis-dir)
"/mastodon-custom-emojis/"))
(when (y-or-n-p "Looks like you haven't downloaded your
instance's custom emoji yet. Download now? ")
(mastodon-toot--download-custom-emoji)))
(setq emojify-user-emojis
(append (mastodon-toot--collect-custom-emoji)
emojify-user-emojis))
;; if already loaded, reload
(when (featurep 'emojify)
(emojify-set-emoji-data)))
(defun mastodon-toot--remove-docs ()
"Get the body of a toot from the current compose buffer."
(let ((header-region (mastodon-tl--find-property-range 'toot-post-header
(point-min))))
(buffer-substring (cdr header-region) (point-max))))
(defun mastodon-toot--build-poll-params ()
"Return an alist of parameters for POSTing a poll status."
(append
(mastodon-http--build-array-params-alist
"poll[options][]"
(plist-get mastodon-toot-poll :options))
`(("poll[expires_in]" . ,(plist-get mastodon-toot-poll :expiry)))
`(("poll[multiple]" . ,(symbol-name (plist-get mastodon-toot-poll :multi))))
`(("poll[hide_totals]" . ,(symbol-name (plist-get mastodon-toot-poll :hide))))))
(defun mastodon-toot--read-cw-string ()
"Read a content warning from the minibuffer."
(when (and (not (mastodon-toot--empty-p))
mastodon-toot--content-warning)
(read-string "Warning: "
mastodon-toot--content-warning-from-reply-or-redraft)))
(defun mastodon-toot--send ()
"POST contents of new-toot buffer to Mastodon instance and kill buffer.
If media items have been attached and uploaded with
`mastodon-toot--attach-media', they are attached to the toot.
If `mastodon-toot--edit-toot-id' is non-nil, PUT contents to
instance to edit a toot."
(interactive)
(let* ((toot (mastodon-toot--remove-docs))
(scheduled mastodon-toot--scheduled-for)
(scheduled-id mastodon-toot--scheduled-id)
(edit-id mastodon-toot--edit-toot-id)
(endpoint
(if edit-id
;; we are sending an edit:
(mastodon-http--api (format "statuses/%s"
edit-id))
(mastodon-http--api "statuses")))
(cw (mastodon-toot--read-cw-string))
(args-no-media (append `(("status" . ,toot)
("in_reply_to_id" . ,mastodon-toot--reply-to-id)
("visibility" . ,mastodon-toot--visibility)
("sensitive" . ,(when mastodon-toot--content-nsfw
(symbol-name t)))
("spoiler_text" . ,cw)
("language" . ,mastodon-toot--language))
;; Pleroma instances can't handle null-valued
;; scheduled_at args, so only add if non-nil
(when scheduled `(("scheduled_at" . ,scheduled)))))
(args-media (when mastodon-toot--media-attachments
(mastodon-http--build-array-params-alist
"media_ids[]"
mastodon-toot--media-attachment-ids)))
(args-poll (when mastodon-toot-poll
(mastodon-toot--build-poll-params)))
;; media || polls:
(args (if mastodon-toot--media-attachments
(append args-media args-no-media)
(if mastodon-toot-poll
(append args-no-media args-poll)
args-no-media)))
(prev-window-config mastodon-toot-previous-window-config))
(cond ((and mastodon-toot--media-attachments
;; make sure we have media args
;; and the same num of ids as attachments
(or (not args-media)
(not (= (length mastodon-toot--media-attachments)
(length mastodon-toot--media-attachment-ids)))))
(message "Something is wrong with your uploads. Wait for them to complete or try again."))
((and mastodon-toot--max-toot-chars
(> (mastodon-toot--count-toot-chars toot cw) mastodon-toot--max-toot-chars))
(message "Looks like your toot (inc. CW) is longer than that maximum allowed length."))
((mastodon-toot--empty-p)
(message "Empty toot. Cowardly refusing to post this."))
(t
(let ((response (if edit-id
;; we are sending an edit:
(mastodon-http--put endpoint args)
(mastodon-http--post endpoint args))))
(mastodon-http--triage
response
(lambda ()
(mastodon-toot--kill)
(if scheduled
(message "Toot scheduled!")
(message "Toot toot!"))
;; cancel scheduled toot if we were editing it:
(when scheduled-id
(mastodon-views--cancel-scheduled-toot
scheduled-id :no-confirm))
(mastodon-toot--restore-previous-window-config prev-window-config)
(when edit-id
(let ((pos (marker-position (cadr prev-window-config))))
(mastodon-tl--reload-timeline-or-profile pos))))))))))
;; EDITING TOOTS:
(defun mastodon-toot--edit-toot-at-point ()
"Edit the user's toot at point."
(interactive)
(let ((toot (or (mastodon-tl--property 'base-toot); fave/boost notifs
(mastodon-tl--property 'toot-json))))
(if (not (mastodon-toot--own-toot-p toot))
(message "You can only edit your own toots.")
(let* ((id (mastodon-tl--as-string (mastodon-tl--toot-id toot)))
(source (mastodon-toot--get-toot-source id))
(content (alist-get 'text source))
(source-cw (alist-get 'spoiler_text source))
(toot-visibility (alist-get 'visibility toot))
(toot-language (alist-get 'language toot))
(reply-id (alist-get 'in_reply_to_id toot)))
(when (y-or-n-p "Edit this toot? ")
(mastodon-toot--compose-buffer nil reply-id nil content :edit)
(goto-char (point-max))
;; (insert content)
;; adopt reply-to-id, visibility, CW, and language:
(mastodon-toot--set-toot-properties reply-id toot-visibility
source-cw toot-language)
(mastodon-toot--update-status-fields)
(setq mastodon-toot--edit-toot-id id))))))
(defun mastodon-toot--get-toot-source (id)
"Fetch the source JSON of toot with ID."
(let ((url (mastodon-http--api (format "/statuses/%s/source" id))))
(mastodon-http--get-json url nil :silent)))
(defun mastodon-toot--get-toot-edits (id)
"Return the edit history of toot with ID."
(let* ((url (mastodon-http--api (format "statuses/%s/history" id))))
(mastodon-http--get-json url)))
(defun mastodon-toot--view-toot-edits ()
"View editing history of the toot at point in a popup buffer."
(interactive)
(let ((id (mastodon-tl--property 'base-toot-id))
(history (mastodon-tl--property 'edit-history))
(buf "*mastodon-toot-edits*"))
(with-mastodon-buffer buf #'special-mode :other-window
(let ((count 1))
(mapc (lambda (x)
(insert (propertize (if (= count 1)
(format "%s [original]:\n" count)
(format "%s:\n" count))
'face font-lock-comment-face)
(mastodon-toot--insert-toot-iter x)
"\n")
(cl-incf count))
history))
(setq-local header-line-format
(propertize
(format "Edits to toot by %s:"
(alist-get 'username
(alist-get 'account (car history))))
'face font-lock-comment-face))
(mastodon-tl--set-buffer-spec (buffer-name (current-buffer))
(format "statuses/%s/history" id)
nil))))
(defun mastodon-toot--insert-toot-iter (it)
"Insert iteration IT of toot."
(let ((content (alist-get 'content it)))
;; (account (alist-get 'account it))
;; TODO: handle polls, media
(mastodon-tl--render-text content)))
(defun mastodon-toot--restore-previous-window-config (config)
"Restore the window CONFIG after killing the toot compose buffer.
Buffer-local variable `mastodon-toot-previous-window-config' holds the config."
(set-window-configuration (car config))
(goto-char (cadr config)))
(defun mastodon-toot--mentions-to-string (mentions)
"Apply `mastodon-toot--process-local' function to each mention in MENTIONS.
Remove empty string (self) from result and joins the sequence with whitespace."
(mapconcat (lambda (mention) mention)
(remove "" (mapcar #'mastodon-toot--process-local mentions))
" "))
(defun mastodon-toot--process-local (acct)
"Add domain to local ACCT and replace the curent user name with \"\".
Mastodon requires the full @user@domain, even in the case of local accts.
eg. \"user\" -> \"@user@local.social\" (when local.social is the domain of the
mastodon-instance-url).
eg. \"yourusername\" -> \"\"
eg. \"feduser@fed.social\" -> \"@feduser@fed.social\"."
(cond ((string-match-p "@" acct) (concat "@" acct)) ; federated acct
((string= (mastodon-auth--user-acct) acct) "") ; your acct
(t (concat "@" acct "@" ; local acct
(cadr (split-string mastodon-instance-url "/" t))))))
(defun mastodon-toot--mentions (status)
"Extract mentions (not the reply-to author or booster) from STATUS.
The mentioned users look like this:
Local user (including the logged in): `username`.
Federated user: `username@host.co`."
(let* ((boosted (mastodon-tl--field 'reblog status))
(mentions
(if boosted
(alist-get 'mentions (alist-get 'reblog status))
(alist-get 'mentions status))))
;; reverse does not work on vectors in 24.5
(mastodon-tl--map-alist 'acct (reverse mentions))))
(defun mastodon-toot--get-bounds (regex)
"Get bounds of tag or handle before point using REGEX."
;; needed because # and @ are not part of any existing thing at point
(save-match-data
(save-excursion
;; match full handle inc. domain, or tag including #
;; (see the regexes for subexp 2)
(when (re-search-backward regex
(save-excursion
(forward-whitespace -1)
(point))
:no-error)
(cons (match-beginning 2)
(match-end 2))))))
(defun mastodon-toot--fetch-completion-candidates (start end &optional tags)
"Search for a completion prefix from buffer positions START to END.
Return a list of candidates.
If TAGS, we search for tags, else we search for handles."
;; we can't save the first two-letter search then only filter the
;; resulting list, as max results returned is 40.
(setq mastodon-toot-completions
(if tags
(let ((tags-list (mastodon-search--search-tags-query
(buffer-substring-no-properties start end))))
(cl-loop for tag in tags-list
collect (cons (concat "#" (car tag))
(cdr tag))))
(mastodon-search--search-accounts-query
(buffer-substring-no-properties start end)))))
(defun mastodon-toot--mentions-capf ()
"Build a mentions completion backend for `completion-at-point-functions'."
(let* ((bounds
(mastodon-toot--get-bounds mastodon-toot-handle-regex))
(start (car bounds))
(end (cdr bounds)))
(when bounds
(list start
end
;; only search when necessary:
(completion-table-dynamic
(lambda (_)
;; Interruptible candidate computation
;; suggestion from minad (d mendler), thanks!
(let ((result
(while-no-input
(mastodon-toot--fetch-completion-candidates start end))))
(and (consp result) result))))
:exclusive 'no
:annotation-function
(lambda (candidate)
(concat " "
(mastodon-toot--mentions-annotation-fun candidate)))))))
(defun mastodon-toot--tags-capf ()
"Build a tags completion backend for `completion-at-point-functions'."
(let* ((bounds
(mastodon-toot--get-bounds mastodon-toot-tag-regex))
(start (car bounds))
(end (cdr bounds)))
(when bounds
(list start
end
;; only search when necessary:
(completion-table-dynamic
(lambda (_)
;; Interruptible candidate computation
;; suggestion from minad (d mendler), thanks!
(let ((result
(while-no-input
(mastodon-toot--fetch-completion-candidates start end :tags))))
(and (consp result) result))))
:exclusive 'no
:annotation-function
(lambda (candidate)
(concat " "
(mastodon-toot--tags-annotation-fun candidate)))))))
(defun mastodon-toot--mentions-annotation-fun (candidate)
"Given a handle completion CANDIDATE, return its annotation string, a username."
(caddr (assoc candidate mastodon-toot-completions)))
(defun mastodon-toot--tags-annotation-fun (candidate)
"Given a tag string CANDIDATE, return an annotation, the tag's URL."
;; FIXME check the list returned here? should be cadr
;;or make it an alist and use cdr
(cadr (assoc candidate mastodon-toot-completions)))
(defun mastodon-toot--reply ()
"Reply to toot at `point'.
Customize `mastodon-toot-display-orig-in-reply-buffer' to display
text of the toot being replied to in the compose buffer."
(interactive)
(mastodon-tl--do-if-toot-strict
(let* ((toot (mastodon-tl--property 'toot-json))
;; no-move arg for base toot, because if it doesn't have one, it is
;; fetched from next toot!
(base-toot (mastodon-tl--property 'base-toot :no-move)) ; for new notifs handling
(id (mastodon-tl--as-string (mastodon-tl--field 'id (or base-toot toot))))
(account (mastodon-tl--field 'account toot))
(user (alist-get 'acct account))
(mentions (mastodon-toot--mentions (or base-toot toot)))
(boosted (mastodon-tl--field 'reblog (or base-toot toot)))
(booster (when boosted
(alist-get 'acct
(alist-get 'account toot)))))
(mastodon-toot
(when user
(if booster
(if (and (not (equal user booster))
(not (member booster mentions)))
;; different booster, user and mentions:
(mastodon-toot--mentions-to-string (append (list user booster) mentions nil))
;; booster is either user or in mentions:
(if (not (member user mentions))
;; user not already in mentions:
(mastodon-toot--mentions-to-string (append (list user) mentions nil))
;; user already in mentions:
(mastodon-toot--mentions-to-string (copy-sequence mentions))))
;; ELSE no booster:
(if (not (member user mentions))
;; user not in mentions:
(mastodon-toot--mentions-to-string (append (list user) mentions nil))
;; user in mentions already:
(mastodon-toot--mentions-to-string (copy-sequence mentions)))))
id
(or base-toot toot)))))
(defun mastodon-toot--toggle-warning ()
"Toggle `mastodon-toot--content-warning'."
(interactive)
(setq mastodon-toot--content-warning
(not mastodon-toot--content-warning))
(mastodon-toot--update-status-fields))
(defun mastodon-toot--toggle-nsfw ()
"Toggle `mastodon-toot--content-nsfw'."
(interactive)
(setq mastodon-toot--content-nsfw
(not mastodon-toot--content-nsfw))
(message "NSFW flag is now %s" (if mastodon-toot--content-nsfw "on" "off"))
(mastodon-toot--update-status-fields))
(defun mastodon-toot--change-visibility ()
"Change the current visibility to the next valid value."
(interactive)
(if (mastodon-tl--buffer-type-eq 'edit-toot)
(message "You can't change visibility when editing toots.")
(setq mastodon-toot--visibility
(cond ((string= mastodon-toot--visibility "public")
"unlisted")
((string= mastodon-toot--visibility "unlisted")
"private")
((string= mastodon-toot--visibility "private")
"direct")
(t
"public")))
(mastodon-toot--update-status-fields)))
(defun mastodon-toot--clear-all-attachments ()
"Remove all attachments from a toot draft."
(interactive)
(setq mastodon-toot--media-attachments nil)
(setq mastodon-toot--media-attachment-ids nil)
(mastodon-toot--refresh-attachments-display)
(mastodon-toot--update-status-fields))
(defun mastodon-toot--attach-media (file description)
"Prompt for an attachment FILE with DESCRIPTION.
A preview is displayed in the new toot buffer, and the file
is uploaded asynchronously using `mastodon-toot--upload-attached-media'.
File is actually attached to the toot upon posting."
(interactive "fFilename: \nsDescription: ")
(when (>= (length mastodon-toot--media-attachments) 4)
;; Only a max. of 4 attachments are allowed, so pop the oldest one.
(pop mastodon-toot--media-attachments))
(if (file-directory-p file)
(message "Looks like you chose a directory not a file.")
(setq mastodon-toot--media-attachments
(nconc mastodon-toot--media-attachments
`(((:contents . ,(mastodon-http--read-file-as-string file))
(:description . ,description)
(:filename . ,file)))))
(mastodon-toot--refresh-attachments-display)
;; upload only most recent attachment:
(mastodon-toot--upload-attached-media (car (last mastodon-toot--media-attachments)))))
(defun mastodon-toot--upload-attached-media (attachment)
"Upload a single ATTACHMENT using `mastodon-http--post-media-attachment'.
The item's id is added to `mastodon-toot--media-attachment-ids',
which is used to attach it to a toot when posting."
(let* ((filename (expand-file-name
(alist-get :filename attachment)))
(caption (alist-get :description attachment))
(url (concat mastodon-instance-url "/api/v2/media")))
(message "Uploading %s..." (file-name-nondirectory filename))
(mastodon-http--post-media-attachment url filename caption)))
(defun mastodon-toot--refresh-attachments-display ()
"Update the display attachment previews in toot draft buffer."
(let ((inhibit-read-only t)
(attachments-region (mastodon-tl--find-property-range
'toot-attachments (point-min)))
(display-specs (mastodon-toot--format-attachments)))
(dotimes (i (- (cdr attachments-region) (car attachments-region)))
(add-text-properties (+ (car attachments-region) i)
(+ (car attachments-region) i 1)
(list 'display (or (nth i display-specs) ""))))))
(defun mastodon-toot--format-attachments ()
"Format the attachment previews for display in toot draft buffer."
(or (let ((counter 0)
(image-options (when (or (image-type-available-p 'imagemagick)
(image-transforms-p))
`(:height ,mastodon-toot--attachment-height))))
(mapcan (lambda (attachment)
(let* ((data (alist-get :contents attachment))
(image (apply #'create-image data
(if (version< emacs-version "27.1")
(when image-options 'imagemagick)
nil) ; inbuilt scaling in 27.1
t image-options))
(description (alist-get :description attachment)))
(setq counter (1+ counter))
(list (format "\n %d: " counter)
image
(format " \"%s\"" description))))
mastodon-toot--media-attachments))
(list "None")))
(defun mastodon-toot--fetch-max-poll-options (instance)
"Return the maximum number of poll options from INSTANCE, which is json."
(mastodon-toot--fetch-poll-field 'max_options instance))
(defun mastodon-toot--fetch-max-poll-option-chars (instance)
"Return the maximum number of characters a poll option may have.
INSTANCE is JSON."
(if (alist-get 'pleroma instance)
(mastodon-toot--fetch-poll-field 'max_option_chars instance)
(or (mastodon-toot--fetch-poll-field 'max_characters_per_option instance)
50))) ; masto default
(defun mastodon-toot--fetch-poll-field (field instance)
"Return FIELD from the poll settings from INSTANCE, which is json."
(let* ((polls (if (alist-get 'pleroma instance)
(alist-get 'poll_limits instance)
(alist-get 'polls
(alist-get 'configuration instance)))))
(alist-get field polls)))
(defun mastodon-toot--read-poll-options-count (max)
"Read the user's choice of the number of options the poll should have.
MAX is the maximum number set by their instance."
(let ((number (read-number
(format "Number of options [2-%s]: " max) 2)))
(if (> number max)
(error "You need to choose a number between 2 and %s" max)
number)))
(defun mastodon-toot--create-poll ()
"Prompt for new poll options and return as a list."
(interactive)
;; re length, API docs show a poll 9 options.
(let* ((instance (mastodon-http--get-json (mastodon-http--api "instance")))
(max-options (mastodon-toot--fetch-max-poll-options instance))
(count (mastodon-toot--read-poll-options-count max-options))
(length (mastodon-toot--fetch-max-poll-option-chars instance))
(multiple-p (y-or-n-p "Multiple choice? "))
(options (mastodon-toot--read-poll-options count length))
(hide-totals (y-or-n-p "Hide votes until poll ends? "))
(expiry (mastodon-toot--read-poll-expiry)))
(setq mastodon-toot-poll
`(:options ,options :length ,length :multi ,multiple-p
:hide ,hide-totals :expiry ,expiry))
(message "poll created!")))
(defun mastodon-toot--read-poll-options (count length)
"Read a list of options for poll with COUNT options.
LENGTH is the maximum character length allowed for a poll option."
(let* ((choices
(cl-loop for x from 1 to count
collect (read-string
(format "Poll option [%s/%s] [max %s chars]: "
x count length))))
(longest (cl-reduce #'max (mapcar #'length choices))))
(if (> longest length)
(progn
(message "looks like you went over the max length. Try again.")
(sleep-for 2)
(mastodon-toot--read-poll-options count length))
choices)))
(defun mastodon-toot--read-poll-expiry ()
"Prompt for a poll expiry time."
;; API requires this in seconds
(let* ((options (mastodon-toot--poll-expiry-options-alist))
(response (completing-read "poll ends in [or enter seconds]: "
options nil 'confirm)))
(or (alist-get response options nil nil #'equal)
(if (< (string-to-number response) 600)
"600" ;; min 5 mins
response))))
(defun mastodon-toot--poll-expiry-options-alist ()
"Return an alist of seconds options."
`(("5 minutes" . ,(number-to-string (* 60 5)))
("30 minutes" . ,(number-to-string (* 60 30)))
("1 hour" . ,(number-to-string (* 60 60)))
("6 hours" . ,(number-to-string (* 60 60 6)))
("1 day" . ,(number-to-string (* 60 60 24)))
("3 days" . ,(number-to-string (* 60 60 24 3)))
("7 days" . ,(number-to-string (* 60 60 24 7)))
("14 days" . ,(number-to-string (* 60 60 24 14)))
("30 days" . ,(number-to-string (* 60 60 24 30)))))
(defun mastodon-toot--set-toot-language ()
"Prompt for a language and set `mastodon-toot--language'.
Return its two letter ISO 639 1 code."
(interactive)
(let* ((choice (completing-read "Language for this toot: "
mastodon-iso-639-1)))
(setq mastodon-toot--language
(alist-get choice mastodon-iso-639-1 nil nil 'equal))
(message "Language set to %s" choice)
(mastodon-toot--update-status-fields)))
(defun mastodon-toot--schedule-toot (&optional reschedule)
"Read a date (+ time) in the minibuffer and schedule the current toot.
With RESCHEDULE, reschedule the scheduled toot at point without editing."
;; original idea by christian tietze, thanks!
;; https://codeberg.org/martianh/mastodon.el/issues/285
(interactive)
(cond ((mastodon-tl--buffer-type-eq 'edit-toot)
(message "You can't schedule toots you're editing."))
((not (or (mastodon-tl--buffer-type-eq 'new-toot)
(mastodon-tl--buffer-type-eq 'scheduled-statuses)))
(message "You can only schedule toots from the compose toot buffer or the scheduled toots view."))
(t
(let* ((id (when reschedule (mastodon-tl--property 'id :no-move)))
(ts (when reschedule
(alist-get 'scheduled_at
(mastodon-tl--property 'scheduled-json :no-move))))
(time-value
(org-read-date t t nil "Schedule toot:"
;; default to scheduled timestamp if already set:
(mastodon-toot--iso-to-org
;; we are rescheduling without editing:
(or ts
;; we are maybe editing the scheduled toot:
mastodon-toot--scheduled-for))))
(iso8601-str (format-time-string "%FT%T%z" time-value))
(msg-str (format-time-string "%d-%m-%y at %H:%M[%z]" time-value)))
(if (not reschedule)
(progn
(setq-local mastodon-toot--scheduled-for iso8601-str)
(message (format "Toot scheduled for %s." msg-str)))
(let* ((args (when reschedule `(("scheduled_at" . ,iso8601-str))))
(url (when reschedule (mastodon-http--api
(format "scheduled_statuses/%s" id))))
(response (mastodon-http--put url args)))
(mastodon-http--triage response
(lambda ()
;; reschedule means we are in scheduled toots view:
(mastodon-views--view-scheduled-toots)
(message
(format "Toot rescheduled for %s." msg-str))))))))))
(defun mastodon-toot--iso-to-human (ts)
"Format an ISO8601 timestamp TS to be more human-readable."
(let* ((decoded (iso8601-parse ts))
(encoded (encode-time decoded)))
(format-time-string "%d-%m-%y, %H:%M[%z]" encoded)))
(defun mastodon-toot--iso-to-org (ts)
"Convert ISO8601 timestamp TS to something `org-read-date' can handle."
(when ts (let* ((decoded (iso8601-parse ts)))
(encode-time decoded))))
;; we'll need to revisit this if the binds get
;; more diverse than two-chord bindings
(defun mastodon-toot--get-mode-kbinds ()
"Get a list of the keybindings in the mastodon-toot-mode."
(let* ((binds (copy-tree mastodon-toot-mode-map))
(prefix (car (cadr binds)))
(bindings (remove nil (mapcar (lambda (i) (if (listp i) i))
(cadr binds)))))
(mapcar (lambda (b)
(setf (car b) (vector prefix (car b)))
b)
bindings)))
(defun mastodon-toot--format-kbind-command (cmd)
"Format CMD to be more readable.
e.g. mastodon-toot--send -> Send."
(let* ((str (symbol-name cmd))
(re "--\\(.*\\)$")
(str2 (save-match-data
(string-match re str)
(match-string 1 str))))
(capitalize (replace-regexp-in-string "-" " " str2))))
(defun mastodon-toot--format-kbind (kbind)
"Format a single keybinding, KBIND, for display in documentation."
(let ((key (help-key-description (car kbind) nil))
(command (mastodon-toot--format-kbind-command (cdr kbind))))
(format " %s - %s" key command)))
(defun mastodon-toot--format-kbinds (kbinds)
"Format a list of keybindings, KBINDS, for display in documentation."
(mapcar #'mastodon-toot--format-kbind kbinds))
(defvar-local mastodon-toot--kbinds-pairs nil
"Contains a list of paired toot compose buffer keybindings for inserting.")
(defun mastodon-toot--formatted-kbinds-pairs (kbinds-list longest)
"Return a list of strings each containing two formatted kbinds.
KBINDS-LIST is the list of formatted bindings to pair.
LONGEST is the length of the longest binding."
(when kbinds-list
(push (concat "\n"
(car kbinds-list)
(make-string (- (1+ longest) (length (car kbinds-list)))
?\ )
(cadr kbinds-list))
mastodon-toot--kbinds-pairs)
(mastodon-toot--formatted-kbinds-pairs (cddr kbinds-list) longest))
(reverse mastodon-toot--kbinds-pairs))
(defun mastodon-toot--formatted-kbinds-longest (kbinds-list)
"Return the length of the longest item in KBINDS-LIST."
(let ((lengths (mapcar (lambda (x)
(length x))
kbinds-list)))
(car (sort lengths #'>))))
(defun mastodon-toot--make-mode-docs ()
"Create formatted documentation text for the mastodon-toot-mode."
(let* ((kbinds (mastodon-toot--get-mode-kbinds))
(longest-kbind
(mastodon-toot--formatted-kbinds-longest
(mastodon-toot--format-kbinds kbinds))))
(concat
" Compose a new toot here. The following keybindings are available:"
(mapconcat #'identity
(mastodon-toot--formatted-kbinds-pairs
(mastodon-toot--format-kbinds kbinds)
longest-kbind)
nil))))
(defun mastodon-toot--format-reply-in-compose-string (reply-text)
"Format a REPLY-TEXT for display in compose buffer docs."
(let* ((rendered (mastodon-tl--render-text reply-text))
(no-props (substring-no-properties rendered))
;; FIXME: this regex replaces \n at end of every post
;; so we have to trim:
(no-newlines (string-trim
(replace-regexp-in-string "[\n]+" " " no-props)))
(reply-to (concat " Reply to: \"" no-newlines "\""))
(crop (truncate-string-to-width
;; (string-limit
reply-to
mastodon-toot-orig-in-reply-length)))
(if (> (length no-newlines)
(length crop)) ; we cropped:
(concat crop "\n")
(concat reply-to "\n"))))
(defun mastodon-toot--display-docs-and-status-fields (&optional reply-text)
"Insert propertized text with documentation about `mastodon-toot-mode'.
Also includes and the status fields which will get updated based
on the status of NSFW, content warning flags, media attachments, etc.
REPLY-TEXT is the text of the toot being replied to."
(let ((divider
"|=================================================================|"))
(insert
(propertize
(concat
(mastodon-toot--make-mode-docs) "\n"
divider "\n"
" "
(propertize "Count"
'toot-post-counter t)
" ⋅ "
(propertize "Visibility"
'toot-post-visibility t)
" ⋅ "
(propertize "Language"
'toot-post-language t)
" "
(propertize "Scheduled"
'toot-post-scheduled t)
" "
(propertize "CW"
'toot-post-cw-flag t)
" "
(propertize "NSFW"
'toot-post-nsfw-flag t)
"\n"
" Attachments: "
(propertize "None "
'toot-attachments t)
"\n"
(if reply-text
(propertize
(mastodon-toot--format-reply-in-compose-string reply-text)
'toot-reply t)
"")
divider
"\n")
'rear-nonsticky t
'face 'mastodon-toot-docs-face
'read-only "Edit your message below."
'toot-post-header t))))
(defun mastodon-toot--most-restrictive-visibility (reply-visibility)
"Return REPLY-VISIBILITY or default visibility, whichever is more restrictive.
The default is given by `mastodon-toot--default-reply-visibility'."
(unless (null reply-visibility)
(let ((less-restrictive (member (intern mastodon-toot--default-reply-visibility)
mastodon-toot-visibility-list)))
(if (member (intern reply-visibility) less-restrictive)
mastodon-toot--default-reply-visibility reply-visibility))))
(defun mastodon-toot--setup-as-reply (reply-to-user reply-to-id reply-json)
"If REPLY-TO-USER is provided, inject their handle into the message.
If REPLY-TO-ID is provided, set `mastodon-toot--reply-to-id'.
REPLY-JSON is the full JSON of the toot being replied to."
(let ((reply-visibility
(mastodon-toot--most-restrictive-visibility
(alist-get 'visibility reply-json)))
(reply-cw (alist-get 'spoiler_text reply-json)))
(when reply-to-user
(when (> (length reply-to-user) 0) ; self is "" unforch
(insert (format "%s " reply-to-user)))
(setq mastodon-toot--reply-to-id reply-to-id)
(unless (equal mastodon-toot--visibility reply-visibility)
(setq mastodon-toot--visibility reply-visibility))
(mastodon-toot--set-cw reply-cw))))
(defun mastodon-toot--update-status-fields (&rest _args)
"Update the status fields in the header based on the current state."
(ignore-errors ;; called from after-change-functions so let's not leak errors
(let* ((inhibit-read-only t)
(header-region (mastodon-tl--find-property-range 'toot-post-header
(point-min)))
(count-region (mastodon-tl--find-property-range 'toot-post-counter
(point-min)))
(visibility-region (mastodon-tl--find-property-range
'toot-post-visibility (point-min)))
(nsfw-region (mastodon-tl--find-property-range 'toot-post-nsfw-flag
(point-min)))
(cw-region (mastodon-tl--find-property-range 'toot-post-cw-flag
(point-min)))
(lang-region (mastodon-tl--find-property-range 'toot-post-language
(point-min)))
(scheduled-region (mastodon-tl--find-property-range 'toot-post-scheduled
(point-min)))
(toot-string (buffer-substring-no-properties (cdr header-region)
(point-max))))
(add-text-properties (car count-region) (cdr count-region)
(list 'display
(format "%s/%s chars"
(mastodon-toot--count-toot-chars toot-string)
(number-to-string mastodon-toot--max-toot-chars))))
(add-text-properties (car visibility-region) (cdr visibility-region)
(list 'display
(format "%s"
(if (equal
mastodon-toot--visibility
"private")
"followers-only"
mastodon-toot--visibility))))
(add-text-properties (car lang-region) (cdr lang-region)
(list 'display
(if mastodon-toot--language
(format "Lang: %s ⋅"
mastodon-toot--language)
"")))
(add-text-properties (car scheduled-region) (cdr scheduled-region)
(list 'display
(if mastodon-toot--scheduled-for
(format "Scheduled: %s ⋅"
(mastodon-toot--iso-to-human
mastodon-toot--scheduled-for))
"")))
(add-text-properties (car nsfw-region) (cdr nsfw-region)
(list 'display (if mastodon-toot--content-nsfw
(if mastodon-toot--media-attachments
"NSFW" "NSFW (for attachments only)")
"")
'face 'mastodon-cw-face))
(add-text-properties (car cw-region) (cdr cw-region)
(list 'invisible (not mastodon-toot--content-warning)
'face 'mastodon-cw-face)))))
(defun mastodon-toot--count-toot-chars (toot-string &optional cw)
"Count the characters in TOOT-STRING.
URLs always = 23, and domain names of handles are not counted.
This is how mastodon does it.
CW is the content warning, which contributes to the character count."
(with-temp-buffer
(switch-to-buffer (current-buffer))
(insert toot-string)
(goto-char (point-min))
;; handle URLs
(while (search-forward-regexp "\\w+://[^ \n]*" nil t) ; URL
(replace-match "xxxxxxxxxxxxxxxxxxxxxxx")) ; 23 x's
;; handle @handles
(goto-char (point-min))
(while (search-forward-regexp (concat "\\(?2:@[^ @\n]+\\)" ; a handle only
"\\(@[^ \n]+\\)?" ; with poss domain
"\\b")
nil t)
(replace-match (match-string 2))) ; replace with handle only
(+ (length cw)
(length (buffer-substring (point-min) (point-max))))))
(defun mastodon-toot--save-toot-text (&rest _args)
"Save the current toot text in `mastodon-toot-current-toot-text'.
Added to `after-change-functions' in new toot buffers."
(let ((text (mastodon-toot--remove-docs)))
(unless (string-empty-p text)
(setq mastodon-toot-current-toot-text text))))
(defun mastodon-toot--open-draft-toot ()
"Prompt for a draft and compose a toot with it."
(interactive)
(if mastodon-toot-draft-toots-list
(let ((text (completing-read "Select draft toot: "
mastodon-toot-draft-toots-list
nil t)))
(if (mastodon-toot--compose-buffer-p)
(when (and (not (mastodon-toot--empty-p :text-only))
(y-or-n-p "Replace current text with draft?"))
(cl-pushnew mastodon-toot-current-toot-text
mastodon-toot-draft-toots-list)
(goto-char
(cdr (mastodon-tl--find-property-range 'toot-post-header
(point-min))))
(kill-region (point) (point-max))
;; to not save to kill-ring:
;; (delete-region (point) (point-max))
(insert text))
(mastodon-toot--compose-buffer nil nil nil text)))
(unless (mastodon-toot--compose-buffer-p)
(mastodon-toot--compose-buffer))
(message "No drafts available.")))
(defun mastodon-toot--delete-draft-toot ()
"Prompt for a draft toot and delete it."
(interactive)
(if mastodon-toot-draft-toots-list
(let ((draft (completing-read "Select draft to delete: "
mastodon-toot-draft-toots-list
nil t)))
(setq mastodon-toot-draft-toots-list
(cl-delete draft mastodon-toot-draft-toots-list
:test 'equal))
(message "Draft deleted!"))
(message "No drafts to delete.")))
(defun mastodon-toot--delete-all-drafts ()
"Delete all drafts."
(interactive)
(setq mastodon-toot-draft-toots-list nil)
(message "All drafts deleted!"))
(defun mastodon-toot--propertize-tags-and-handles (&rest _args)
"Propertize tags and handles in toot compose buffer.
Added to `after-change-functions'."
(when (mastodon-toot--compose-buffer-p)
(let ((header-region
(mastodon-tl--find-property-range 'toot-post-header
(point-min)))
(face (when mastodon-toot--proportional-fonts-compose
'variable-pitch)))
;; cull any prev props:
;; stops all text after a handle or mention being propertized:
(set-text-properties (cdr header-region) (point-max) `(face ,face))
(mastodon-toot--propertize-item mastodon-toot-tag-regex
'success
(cdr header-region))
(mastodon-toot--propertize-item mastodon-toot-handle-regex
'mastodon-display-name-face
(cdr header-region))
(mastodon-toot--propertize-item mastodon-toot-url-regex
'link
(cdr header-region)))))
(defun mastodon-toot--propertize-item (regex face start)
"Propertize item matching REGEX with FACE starting from START."
(save-excursion
(goto-char start)
(cl-loop while (search-forward-regexp regex nil :noerror)
do (add-text-properties (match-beginning 2)
(match-end 2)
`(face ,face)))))
(defun mastodon-toot--compose-buffer-p ()
"Return t if compose buffer is current."
(or (mastodon-tl--buffer-type-eq 'edit-toot)
(mastodon-tl--buffer-type-eq 'new-toot)))
(defun mastodon-toot--fill-reply-in-compose ()
"Fill reply text in compose buffer to the width of the divider."
(save-excursion
(save-match-data
(let* ((fill-column 67))
(goto-char (point-min))
;; while-let shoulndn't be needed here, as we really should only have
;; one. if we have more, the bug is elsewhere.
(when-let ((prop (text-property-search-forward 'toot-reply)))
(fill-region (prop-match-beginning prop)
(point)))))))
;; NB: now that we have toot drafts, to ensure offline composing remains
;; possible, avoid any direct requests here:
(defun mastodon-toot--compose-buffer
(&optional reply-to-user reply-to-id reply-json initial-text edit)
"Create a new buffer to capture text for a new toot.
If REPLY-TO-USER is provided, inject their handle into the message.
If REPLY-TO-ID is provided, set the `mastodon-toot--reply-to-id' var.
REPLY-JSON is the full JSON of the toot being replied to.
INITIAL-TEXT is used by `mastodon-toot-insert-draft-toot' to add
a draft into the buffer.
EDIT means we are editing an existing toot, not composing a new one."
(let* ((buffer-name (if edit "*edit toot*" "*new toot*"))
(buffer-exists (get-buffer buffer-name))
(buffer (or buffer-exists (get-buffer-create buffer-name)))
(inhibit-read-only t)
(reply-text (alist-get 'content
(or (alist-get 'reblog reply-json)
reply-json)))
(previous-window-config (list (current-window-configuration)
(point-marker))))
(switch-to-buffer-other-window buffer)
(text-mode)
(mastodon-toot-mode t)
(setq mastodon-toot--visibility
(or (plist-get mastodon-profile-account-settings 'privacy)
;; use toot visibility setting from the server:
(mastodon-profile--get-source-pref 'privacy)
"public")) ; fallback
(unless buffer-exists
(if mastodon-toot-display-orig-in-reply-buffer
(progn
(mastodon-toot--display-docs-and-status-fields reply-text)
(mastodon-toot--fill-reply-in-compose))
(mastodon-toot--display-docs-and-status-fields))
;; `reply-to-user' (alone) is also used by `mastodon-tl--dm-user', so
;; perhaps we should not always call --setup-as-reply, or make its
;; workings conditional on reply-to-id. currently it only checks for
;; reply-to-user.
(mastodon-toot--setup-as-reply reply-to-user reply-to-id reply-json))
(unless mastodon-toot--max-toot-chars
;; no need to fetch from `mastodon-profile-account-settings' as
;; `mastodon-toot--max-toot-chars' is set when we set it
(mastodon-toot--get-max-toot-chars))
;; set up completion:
(when mastodon-toot--enable-completion
(set ; (setq-local
(make-local-variable 'completion-at-point-functions)
(add-to-list 'completion-at-point-functions
#'mastodon-toot--mentions-capf))
(add-to-list 'completion-at-point-functions
#'mastodon-toot--tags-capf)
;; company
(when (and mastodon-toot--use-company-for-completion
(require 'company nil :no-error))
(declare-function company-mode-on "company")
(set (make-local-variable 'company-backends)
(add-to-list 'company-backends 'company-capf))
(company-mode-on)))
;; after-change:
(make-local-variable 'after-change-functions)
(cl-pushnew #'mastodon-toot--update-status-fields after-change-functions)
(cl-pushnew #'mastodon-toot--save-toot-text after-change-functions)
(cl-pushnew #'mastodon-toot--propertize-tags-and-handles after-change-functions)
(mastodon-toot--update-status-fields)
(mastodon-toot--propertize-tags-and-handles)
(mastodon-toot--refresh-attachments-display)
;; draft toot text saving:
(setq mastodon-toot-current-toot-text nil)
;; if we set this before changing modes, it gets nuked:
(setq mastodon-toot-previous-window-config previous-window-config)
(when mastodon-toot--proportional-fonts-compose
(facemenu-set-face 'variable-pitch))
(when initial-text
(insert initial-text))))
;; flyspell ignore masto toot regexes:
(defvar flyspell-generic-check-word-predicate)
(defun mastodon-toot-mode-flyspell-verify ()
"A predicate function for `flyspell'.
Only text that is not one of these faces will be spell-checked."
(let ((faces '(mastodon-display-name-face
mastodon-toot-docs-face font-lock-comment-face
success link)))
(unless (eql (point) (point-min))
;; (point) is next char after the word. Must check one char before.
(let ((f (get-text-property (1- (point)) 'face)))
(not (memq f faces))))))
(add-hook 'mastodon-toot-mode-hook
(lambda ()
(setq flyspell-generic-check-word-predicate
'mastodon-toot-mode-flyspell-verify)))
;;;###autoload
(add-hook 'mastodon-toot-mode-hook
#'mastodon-profile--fetch-server-account-settings-maybe)
;; disable auto-fill-mode:
(add-hook 'mastodon-toot-mode-hook
(lambda ()
(auto-fill-mode -1)))
(define-minor-mode mastodon-toot-mode
"Minor mode to capture Mastodon toots."
:keymap mastodon-toot-mode-map
:global nil)
(provide 'mastodon-toot)
;;; mastodon-toot.el ends here
;;; mastodon-tl.el --- Timeline functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-tl.el provides timeline functions.
;;; Code:
(require 'shr)
(require 'ts)
(require 'thingatpt) ; for word-at-point
(require 'time-date)
(require 'cl-lib)
(require 'mastodon-iso)
(require 'mpv nil :no-error)
(autoload 'mastodon-mode "mastodon")
(autoload 'mastodon-notifications-get "mastodon")
(autoload 'mastodon-url-lookup "mastodon")
(autoload 'mastodon-auth--get-account-id "mastodon-auth")
(autoload 'mastodon-auth--get-account-name "mastodon-auth")
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--build-array-params-alist "mastodon-http")
(autoload 'mastodon-http--build-params-string "mastodon-http")
(autoload 'mastodon-http--delete "mastodon-http")
(autoload 'mastodon-http--get-json "mastodon-http")
(autoload 'mastodon-http--get-json-async "mastodon-http")
(autoload 'mastodon-http--get-response-async "mastodon-http")
(autoload 'mastodon-http--post "mastodon-http")
(autoload 'mastodon-http--process-json "mastodon-http")
(autoload 'mastodon-http--put "mastodon-http")
(autoload 'mastodon-http--triage "mastodon-http")
(autoload 'mastodon-media--get-avatar-rendering "mastodon-media")
(autoload 'mastodon-media--get-media-link-rendering "mastodon-media")
(autoload 'mastodon-media--inline-images "mastodon-media")
(autoload 'mastodon-notifications--filter-types-list "mastodon-notifications")
(autoload 'mastodon-notifications--get-mentions "mastodon-notifications")
(autoload 'mastodon-profile--account-field "mastodon-profile")
(autoload 'mastodon-profile--account-from-id "mastodon-profile")
(autoload 'mastodon-profile--extract-users-handles "mastodon-profile")
(autoload 'mastodon-profile--get-preferences-pref "mastodon-profile")
(autoload 'mastodon-profile--get-toot-author "mastodon-profile")
(autoload 'mastodon-profile--lookup-account-in-status "mastodon-profile")
(autoload 'mastodon-profile--make-author-buffer "mastodon-profile")
(autoload 'mastodon-profile--my-profile "mastodon-profile")
(autoload 'mastodon-profile--open-statuses-no-reblogs "mastodon-profile")
(autoload 'mastodon-profile--profile-json "mastodon-profile")
(autoload 'mastodon-profile--search-account-by-handle "mastodon-profile")
(autoload 'mastodon-profile--toot-json "mastodon-profile")
(autoload 'mastodon-profile--view-author-profile "mastodon-profile")
(autoload 'mastodon-profile-mode "mastodon-profile")
(autoload 'mastodon-search--get-user-info "mastodon-search")
(autoload 'mastodon-search--insert-users-propertized "mastodon-search")
(autoload 'mastodon-search--propertize-user "mastodon-search")
(autoload 'mastodon-toot--compose-buffer "mastodon-toot")
(autoload 'mastodon-toot--delete-toot "mastodon-toot")
(autoload 'mastodon-toot--get-toot-edits "mastodon-toot")
(autoload 'mastodon-toot--iso-to-human "mastodon-toot")
(autoload 'mastodon-toot--schedule-toot "mastodon-toot")
(autoload 'mastodon-toot--set-toot-properties "mastodon-toot")
(autoload 'mastodon-toot--update-status-fields "mastodon-toot")
(defvar mastodon-toot--visibility)
(defvar mastodon-toot-mode)
(defvar mastodon-active-user)
(when (require 'mpv nil :no-error)
(declare-function mpv-start "mpv"))
(defvar mastodon-instance-url)
(defvar mastodon-toot-timestamp-format)
(defvar shr-use-fonts) ;; declare it since Emacs24 didn't have this
(defvar mastodon-mode-map)
;;; CUSTOMIZES
(defgroup mastodon-tl nil
"Timelines in Mastodon."
:prefix "mastodon-tl-"
:group 'mastodon)
(defcustom mastodon-tl--enable-relative-timestamps t
"Whether to show relative (to the current time) timestamps.
This will require periodic updates of a timeline buffer to
keep the timestamps current as time progresses."
:type '(boolean :tag "Enable relative timestamps and background updater task"))
(defcustom mastodon-tl--enable-proportional-fonts nil
"Nonnil to enable using proportional fonts when rendering HTML.
By default fixed width fonts are used."
:type '(boolean :tag "Enable using proportional rather than fixed \
width fonts when rendering HTML text"))
(defcustom mastodon-tl--display-caption-not-url-when-no-media t
"Display an image's caption rather than URL.
Only has an effect when `mastodon-tl--display-media-p' is set to
nil."
:type 'boolean)
(defcustom mastodon-tl--show-avatars nil
"Whether to enable display of user avatars in timelines."
:type '(boolean :tag "Whether to display user avatars in timelines"))
(defcustom mastodon-tl--show-stats t
"Whether to show toot stats (faves, boosts, replies counts)."
:type 'bool)
(defcustom mastodon-tl--symbols
'((reply . ("💬" . "R"))
(boost . ("🔁" . "B"))
(favourite . ("⭐" . "F"))
(bookmark . ("🔖" . "K"))
(media . ("📹" . "[media]"))
(verified . ("" . "V"))
(locked . ("🔒" . "[locked]"))
(private . ("🔒" . "[followers]"))
(direct . ("✉" . "[direct]"))
(edited . ("✍" . "[edited]"))
(replied . ("⬇" . "↓"))
(reply-bar . ("┃" . "|")))
"A set of symbols (and fallback strings) to be used in timeline.
If a symbol does not look right (tofu), it means your
font settings do not support it."
:type '(alist :key-type symbol :value-type string))
(defcustom mastodon-tl-position-after-update nil
"Defines where `point' should be located after a timeline update.
Valid values are:
- nil Top/bottom depending on timeline type
- keep-point Keep original position of point
- last-old-toot The last toot before the new ones"
:type '(choice (const :tag "Top/bottom depending on timeline type" nil)
(const :tag "Keep original position of point" keep-point)
(const :tag "The last toot before the new ones" last-old-toot)))
(defcustom mastodon-tl--timeline-posts-count "20"
"Number of posts to display when loading a timeline.
Must be an integer between 20 and 40 inclusive."
:type '(string))
(defcustom mastodon-tl--hide-replies nil
"Whether to hide replies from the timelines.
Note that you can hide replies on a one-off basis by loading a
timeline with a simple prefix argument, `C-u'."
:type '(boolean :tag "Whether to hide replies from the timelines."))
;;; VARIABLES
(defvar-local mastodon-tl--buffer-spec nil
"A unique identifier and functions for each Mastodon buffer.")
(defvar-local mastodon-tl--update-point nil
"When updating a mastodon buffer this is where new toots will be inserted.
If nil `(point-min)' is used instead.")
(defvar-local mastodon-tl--after-update-marker nil
"Marker defining the position of point after the update is done.")
(defvar mastodon-tl--display-media-p t
"A boolean value stating whether to show media in timelines.")
(defvar-local mastodon-tl--timestamp-next-update nil
"The timestamp when the buffer should next be scanned to update the timestamps.")
(defvar-local mastodon-tl--timestamp-update-timer nil
"The timer that, when set will scan the buffer to update the timestamps.")
(defvar mastodon-tl--horiz-bar
(if (char-displayable-p ?―)
(make-string 12 ?―)
(make-string 12 ?-)))
;;; KEYMAPS
(defvar mastodon-tl--link-keymap
(let ((map (make-sparse-keymap)))
(define-key map [return] 'mastodon-tl--do-link-action-at-point)
(define-key map [mouse-2] 'mastodon-tl--do-link-action)
(define-key map [follow-link] 'mouse-face)
map)
"The keymap for link-like things in buffer (except for shr.el generate links).
This will make the region of text act like like a link with mouse
highlighting, mouse click action tabbing to next/previous link
etc.")
(defvar mastodon-tl--shr-map-replacement
(let ((map (make-sparse-keymap)))
(set-keymap-parent map shr-map)
;; Replace the move to next/previous link bindings with our
;; version that knows about more types of links.
(define-key map [remap shr-next-link] #'mastodon-tl--next-tab-item)
(define-key map [remap shr-previous-link] #'mastodon-tl--previous-tab-item)
;; keep new my-profile binding; shr 'O' doesn't work here anyway
(define-key map (kbd "O") #'mastodon-profile--my-profile)
(define-key map [remap shr-browse-url] #'mastodon-url-lookup)
map)
"The keymap to be set for shr.el generated links that are not images.
We need to override the keymap so tabbing will navigate to all
types of mastodon links and not just shr.el-generated ones.")
(defvar mastodon-tl--shr-image-map-replacement
(let ((map (make-sparse-keymap)))
(set-keymap-parent map (if (boundp 'shr-image-map)
shr-image-map
shr-map))
;; Replace the move to next/previous link bindings with our
;; version that knows about more types of links.
(define-key map [remap shr-next-link] #'mastodon-tl--next-tab-item)
(define-key map [remap shr-previous-link] #'mastodon-tl--previous-tab-item)
;; browse-url loads the preview only, we want browse-image
;; on RET to browse full sized image URL
(define-key map [remap shr-browse-url] #'shr-browse-image)
;; remove shr's u binding, as it the maybe-probe-and-copy-url
;; is already bound to w also
(define-key map (kbd "u") #'mastodon-tl--update)
;; keep new my-profile binding; shr 'O' doesn't work here anyway
(define-key map (kbd "O") #'mastodon-profile--my-profile)
(define-key map (kbd "<C-return>") #'mastodon-tl--mpv-play-video-at-point)
map)
"The keymap to be set for shr.el generated image links.
We need to override the keymap so tabbing will navigate to all
types of mastodon links and not just shr.el-generated ones.")
(defvar mastodon-tl--byline-link-keymap
(when (require 'mpv nil :no-error)
(let ((map (make-sparse-keymap)))
(define-key map (kbd "<C-return>") #'mastodon-tl--mpv-play-video-from-byline)
(define-key map (kbd "RET") #'mastodon-profile--get-toot-author)
map))
"The keymap to be set for the author byline.
It is active where point is placed by `mastodon-tl--goto-next-toot.'")
;;; BUFFER MACRO
(defmacro with-mastodon-buffer (buffer mode-fun other-window &rest body)
"Evaluate BODY in a new or existing buffer called BUFFER.
MODE-FUN is called to set the major mode.
OTHER-WINDOW means call `switch-to-buffer-other-window' rather
than `switch-to-buffer'."
(declare (debug t)
(indent 3))
`(with-current-buffer (get-buffer-create ,buffer)
(let ((inhibit-read-only t))
(erase-buffer)
(funcall ,mode-fun)
(if ,other-window
(switch-to-buffer-other-window ,buffer)
(switch-to-buffer ,buffer))
,@body)))
;;; NAV
(defun mastodon-tl--next-tab-item (&optional previous)
"Move to the next interesting item.
This could be the next toot, link, or image; whichever comes first.
Don't move if nothing else to move to is found, i.e. near the end of the buffer.
This also skips tab items in invisible text, i.e. hidden spoiler text."
(interactive)
(let (next-range
(search-pos (point)))
(while (and (setq next-range
(mastodon-tl--find-next-or-previous-property-range
'mastodon-tab-stop search-pos previous))
(get-text-property (car next-range) 'invisible)
(setq search-pos (if previous
(1- (car next-range))
(1+ (cdr next-range)))))
;; do nothing, all the action is in the while condition
)
(if (null next-range)
(message "Nothing else here.")
(goto-char (car next-range))
(message "%s" (mastodon-tl--property 'help-echo :no-move)))))
(defun mastodon-tl--previous-tab-item ()
"Move to the previous interesting item.
This could be the previous toot, link, or image; whichever comes
first. Don't move if nothing else to move to is found, i.e. near
the start of the buffer. This also skips tab items in invisible
text, i.e. hidden spoiler text."
(interactive)
(mastodon-tl--next-tab-item :previous))
(defun mastodon-tl--goto-toot-pos (find-pos refresh &optional pos)
"Search for toot with FIND-POS.
If search returns nil, execute REFRESH function.
Optionally start from POS."
(let* ((npos (funcall find-pos
(or pos (point))
'byline
(current-buffer))))
(if npos
(if (not (get-text-property npos 'toot-id))
(mastodon-tl--goto-toot-pos find-pos refresh npos)
(goto-char npos)
;; force display of help-echo on moving to a toot byline:
(mastodon-tl--message-help-echo))
(funcall refresh))))
(defun mastodon-tl--goto-next-toot ()
"Jump to next toot header."
(interactive)
(mastodon-tl--goto-toot-pos 'next-single-property-change
'mastodon-tl--more))
(defun mastodon-tl--goto-prev-toot ()
"Jump to last toot header."
(interactive)
(mastodon-tl--goto-toot-pos 'previous-single-property-change
'mastodon-tl--update))
(defun mastodon-tl--goto-first-item ()
"Jump to first toot or item in buffer.
Used on initializing a timeline or thread."
;; goto-next-toot assumes we already have toots, and is therefore
;; incompatible with any view where it is possible to have no items.
;; when that is the case the call to goto-toot-pos loops infinitely
(goto-char (point-min))
(mastodon-tl--goto-next-item))
(defun mastodon-tl--goto-next-item ()
"Jump to next item, e.g. filter or follow request."
(interactive)
(mastodon-tl--goto-toot-pos 'next-single-property-change
'next-line))
(defun mastodon-tl--goto-prev-item ()
"Jump to previous item, e.g. filter or follow request."
(interactive)
(mastodon-tl--goto-toot-pos 'previous-single-property-change
'previous-line))
;;; TIMELINES
(defun mastodon-tl--get-federated-timeline (&optional prefix local)
"Open federated timeline.
If LOCAL, get only local timeline.
With a single PREFIX arg, hide-replies.
With a double PREFIX arg, only show posts with media."
(interactive "p")
(let ((params `(("limit" . ,mastodon-tl--timeline-posts-count))))
;; avoid adding 'nil' to our params alist:
(when (eq prefix 16)
(push '("only_media" . "true") params))
(when local
(push '("local" . "true") params))
(message "Loading federated timeline...")
(mastodon-tl--init (if local "local" "federated")
"timelines/public" 'mastodon-tl--timeline nil
params
(when (eq prefix 4) t))))
(defun mastodon-tl--get-home-timeline (&optional arg)
"Open home timeline.
With a single prefix ARG, hide replies."
(interactive "p")
(message "Loading home timeline...")
(mastodon-tl--init "home" "timelines/home" 'mastodon-tl--timeline nil
`(("limit" . ,mastodon-tl--timeline-posts-count))
(when (eq arg 4) t)))
(defun mastodon-tl--get-local-timeline (&optional prefix)
"Open local timeline.
With a single PREFIX arg, hide-replies.
With a double PREFIX arg, only show posts with media."
(interactive "p")
(message "Loading local timeline...")
(mastodon-tl--get-federated-timeline prefix :local))
(defun mastodon-tl--get-tag-timeline (&optional prefix tag)
"Prompt for tag and opens its timeline.
Optionally load TAG timeline directly.
With a single PREFIX arg, only show posts with media.
With a double PREFIX arg, limit results to your own instance."
(interactive "p")
(let* ((word (or (word-at-point) ""))
(input (or tag (read-string
(format "Load timeline for tag (%s): " word))))
(tag (or tag (if (string-empty-p input) word input))))
(message "Loading timeline for #%s..." tag)
(mastodon-tl--show-tag-timeline prefix tag)))
(defun mastodon-tl--show-tag-timeline (&optional prefix tag)
"Opens a new buffer showing the timeline of posts with hastag TAG.
If TAG is a list, show a timeline for all tags.
With a single PREFIX arg, only show posts with media.
With a double PREFIX arg, limit results to your own instance."
(let ((params `(("limit" . ,mastodon-tl--timeline-posts-count))))
;; avoid adding 'nil' to our params alist:
(when (eq prefix 4)
(push '("only_media" . "true") params))
(when (eq prefix 16)
(push '("local" . "true") params))
(when (listp tag)
(let ((list (mastodon-http--build-array-params-alist "any[]" (cdr tag))))
(while list
(push (pop list) params))))
(mastodon-tl--init (if (listp tag)
"tags-multiple"
(concat "tag-" tag))
(concat "timelines/tag/" (if (listp tag)
;; endpoint must be /tag/:sth
(car tag) tag))
'mastodon-tl--timeline
nil
params)))
;;; BYLINES, etc.
(defun mastodon-tl--message-help-echo ()
"Call message on `help-echo' property at point.
Do so if type of status at poins is not follow_request/follow."
(let ((type (alist-get
'type
(mastodon-tl--property 'toot-json :no-move)))
(echo (mastodon-tl--property 'help-echo :no-move)))
(when echo ; not for followers/following in profile
(unless (or (string= type "follow_request")
(string= type "follow")) ; no counts for these
(message "%s" (mastodon-tl--property 'help-echo :no-move))))))
(defun mastodon-tl--byline-author (toot &optional avatar)
"Propertize author of TOOT.
With arg AVATAR, include the account's avatar image."
(let* ((account (alist-get 'account toot))
(handle (alist-get 'acct account))
(name (if (not (string-empty-p (alist-get 'display_name account)))
(alist-get 'display_name account)
(alist-get 'username account)))
(profile-url (alist-get 'url account))
(avatar-url (alist-get 'avatar account)))
(concat
;; avatar insertion moved up to `mastodon-tl--byline' by default in order
;; to be outside of text prop 'byline t. arg avatar is used by
;; `mastodon-profile--add-author-bylines'
(when (and avatar
mastodon-tl--show-avatars
mastodon-tl--display-media-p
(if (version< emacs-version "27.1")
(image-type-available-p 'imagemagick)
(image-transforms-p)))
(mastodon-media--get-avatar-rendering avatar-url))
(propertize name
'face 'mastodon-display-name-face
;; enable playing of videos when point is on byline:
'attachments (mastodon-tl--get-attachments-for-byline toot)
'keymap mastodon-tl--byline-link-keymap
;; echo faves count when point on post author name:
;; which is where --goto-next-toot puts point.
'help-echo
;; but don't add it to "following"/"follows" on profile views:
;; we don't have a tl--buffer-spec yet:
(unless (or (string-suffix-p "-followers*" (buffer-name))
(string-suffix-p "-following*" (buffer-name)))
(mastodon-tl--format-byline-help-echo toot)))
" ("
(propertize (concat "@" handle)
'face 'mastodon-handle-face
'mouse-face 'highlight
'mastodon-tab-stop 'user-handle
'account account
'shr-url profile-url
'keymap mastodon-tl--link-keymap
'mastodon-handle (concat "@" handle)
'help-echo (concat "Browse user profile of @" handle))
")")))
(defun mastodon-tl--format-byline-help-echo (toot)
"Format a help-echo for byline of TOOT.
Displays a toot's media types and optionally the binding to play
moving image media from the byline.
Used when point is at the start of a byline, i.e. where
`mastodon-tl--goto-next-toot' leaves point."
(let* ((toot-to-count
(or
;; simply praying this order works
(alist-get 'status toot) ; notifications timeline
;; fol-req notif, has 'type
;; placed before boosts coz fol-reqs have a (useless) reblog entry:
(when (and (or (mastodon-tl--buffer-type-eq 'notifications)
(mastodon-tl--buffer-type-eq 'mentions))
(alist-get 'type toot))
toot)
(alist-get 'reblog toot) ; boosts
toot)) ; everything else
(fol-req-p (or (string= (alist-get 'type toot-to-count) "follow")
(string= (alist-get 'type toot-to-count) "follow_request"))))
(unless fol-req-p
(let* ((media-types (mastodon-tl--get-media-types toot))
(format-media (when media-types
(format "media: %s"
(mapconcat #'identity media-types " "))))
(format-media-binding (when (and (or
(member "video" media-types)
(member "gifv" media-types))
(require 'mpv nil :no-error))
(format " | C-RET to view with mpv"))))
(format "%s" (concat format-media format-media-binding))))))
(defun mastodon-tl--get-media-types (toot)
"Return a list of the media attachment types of the TOOT at point."
(let* ((attachments (mastodon-tl--field 'media_attachments toot)))
(mastodon-tl--map-alist 'type attachments)))
(defun mastodon-tl--get-attachments-for-byline (toot)
"Return a list of attachment URLs and types for TOOT.
The result is added as an attachments property to author-byline."
(let ((media-attachments (mastodon-tl--field 'media_attachments toot)))
(mapcar
(lambda (attachement)
(let ((remote-url
(or (alist-get 'remote_url attachement)
;; fallback b/c notifications don't have remote_url
(alist-get 'url attachement)))
(type (alist-get 'type attachement)))
`(:url ,remote-url :type ,type)))
media-attachments)))
(defun mastodon-tl--byline-boosted (toot)
"Add byline for boosted data from TOOT."
(let ((reblog (alist-get 'reblog toot)))
(when reblog
(concat
"\n "
(propertize "Boosted" 'face 'mastodon-boosted-face)
" "
(mastodon-tl--byline-author reblog)))))
(defun mastodon-tl--format-faved-or-boosted-byline (letter)
"Format the byline marker for a boosted or favourited status.
LETTER is a string, F for favourited, B for boosted, or K for bookmarked."
(let ((help-string (cond ((equal letter "F")
"favourited")
((equal letter "B")
"boosted")
((equal letter (or "🔖" "K"))
"bookmarked"))))
(format "(%s) "
(propertize letter 'face 'mastodon-boost-fave-face
;; emojify breaks this for 🔖:
'help-echo (format "You have %s this status."
help-string)))))
(defun mastodon-tl--byline (toot author-byline action-byline &optional detailed-p)
"Generate byline for TOOT.
AUTHOR-BYLINE is a function for adding the author portion of
the byline that takes one variable.
ACTION-BYLINE is a function for adding an action, such as boosting,
favouriting and following to the byline. It also takes a single function.
By default it is `mastodon-tl--byline-boosted'.
DETAILED-P means display more detailed info. For now
this just means displaying toot client."
(let* ((created-time
;; bosts and faves in notifs view
;; (makes timestamps be for the original toot
;; not the boost/fave):
(or (mastodon-tl--field 'created_at
(mastodon-tl--field 'status toot))
;; all other toots, inc. boosts/faves in timelines:
;; (mastodon-tl--field auto fetches from reblogs if needed):
(mastodon-tl--field 'created_at toot)))
(parsed-time (date-to-time created-time))
(faved (equal 't (mastodon-tl--field 'favourited toot)))
(boosted (equal 't (mastodon-tl--field 'reblogged toot)))
(bookmarked (equal 't (mastodon-tl--field 'bookmarked toot)))
(visibility (mastodon-tl--field 'visibility toot))
(account (alist-get 'account toot))
(avatar-url (alist-get 'avatar account))
(edited-time (alist-get 'edited_at toot))
(edited-parsed (when edited-time (date-to-time edited-time))))
(concat
;; Boosted/favourited markers are not technically part of the byline, so
;; we don't propertize them with 'byline t', as per the rest. This
;; ensures that `mastodon-tl--goto-next-toot' puts point on
;; author-byline, not before the (F) or (B) marker. Not propertizing like
;; this makes the behaviour of these markers consistent whether they are
;; displayed for an already boosted/favourited toot or as the result of
;; the toot having just been favourited/boosted.
(concat (when boosted
(mastodon-tl--format-faved-or-boosted-byline
(mastodon-tl--symbol 'boost)))
(when faved
(mastodon-tl--format-faved-or-boosted-byline
(mastodon-tl--symbol 'favourite)))
(when bookmarked
(mastodon-tl--format-faved-or-boosted-byline
(mastodon-tl--symbol 'bookmark))))
;; we remove avatars from the byline also, so that they also do not mess
;; with `mastodon-tl--goto-next-toot':
(when (and mastodon-tl--show-avatars
mastodon-tl--display-media-p
(if (version< emacs-version "27.1")
(image-type-available-p 'imagemagick)
(image-transforms-p)))
(mastodon-media--get-avatar-rendering avatar-url))
(propertize
(concat
;; we propertize help-echo format faves for author name
;; in `mastodon-tl--byline-author'
(funcall author-byline toot)
;; visibility:
(cond ((equal visibility "direct")
(concat " " (mastodon-tl--symbol 'direct)))
((equal visibility "private")
(concat " " (mastodon-tl--symbol 'private))))
(funcall action-byline toot)
" "
(propertize
(format-time-string mastodon-toot-timestamp-format parsed-time)
'timestamp parsed-time
'display (if mastodon-tl--enable-relative-timestamps
(mastodon-tl--relative-time-description parsed-time)
parsed-time))
(when detailed-p
(let* ((app (alist-get 'application toot))
(app-name (alist-get 'name app))
(app-url (alist-get 'website app)))
(when app
(concat
(propertize " via " 'face 'default)
(propertize app-name
'face 'mastodon-display-name-face
'follow-link t
'mouse-face 'highlight
'mastodon-tab-stop 'shr-url
'shr-url app-url
'help-echo app-url
'keymap mastodon-tl--shr-map-replacement)))))
(if edited-time
(concat
" "
(mastodon-tl--symbol 'edited)
" "
(propertize
(format-time-string mastodon-toot-timestamp-format
edited-parsed)
'face font-lock-comment-face
'timestamp edited-parsed
'display (if mastodon-tl--enable-relative-timestamps
(mastodon-tl--relative-time-description edited-parsed)
edited-parsed)))
"")
(propertize (concat "\n " mastodon-tl--horiz-bar)
'face 'default)
(if mastodon-tl--show-stats
(mastodon-tl--toot-stats toot)
"")
"\n")
'favourited-p faved
'boosted-p boosted
'bookmarked-p bookmarked
'edited edited-time
'edit-history (when edited-time
(mastodon-toot--get-toot-edits (alist-get 'id toot)))
'byline t))))
;;; TIMESTAMPS
(defun mastodon-tl--relative-time-details (timestamp &optional current-time)
"Return cons of (descriptive string . next change) for the TIMESTAMP.
Use the optional CURRENT-TIME as the current time (only used for
reliable testing).
The descriptive string is a human readable version relative to
the current time while the next change timestamp give the first
time that this description will change in the future.
TIMESTAMP is assumed to be in the past."
(let* ((now (or current-time (current-time)))
(time-difference (time-subtract now timestamp))
(seconds-difference (float-time time-difference))
(regular-response
(lambda (seconds-difference multiplier unit-name)
(let ((n (floor (+ 0.5 (/ seconds-difference multiplier)))))
(cons (format "%d %ss ago" n unit-name)
(* (+ 0.5 n) multiplier)))))
(relative-result
(cond
((< seconds-difference 60)
(cons "just now"
60))
((< seconds-difference (* 1.5 60))
(cons "1 minute ago"
90)) ;; at 90 secs
((< seconds-difference (* 60 59.5))
(funcall regular-response seconds-difference 60 "minute"))
((< seconds-difference (* 1.5 60 60))
(cons "1 hour ago"
(* 60 90))) ;; at 90 minutes
((< seconds-difference (* 60 60 23.5))
(funcall regular-response seconds-difference (* 60 60) "hour"))
((< seconds-difference (* 1.5 60 60 24))
(cons "1 day ago"
(* 1.5 60 60 24))) ;; at a day and a half
((< seconds-difference (* 60 60 24 6.5))
(funcall regular-response seconds-difference (* 60 60 24) "day"))
((< seconds-difference (* 1.5 60 60 24 7))
(cons "1 week ago"
(* 1.5 60 60 24 7))) ;; a week and a half
((< seconds-difference (* 60 60 24 7 52))
(if (= 52 (floor (+ 0.5 (/ seconds-difference 60 60 24 7))))
(cons "52 weeks ago"
(* 60 60 24 7 52))
(funcall regular-response seconds-difference (* 60 60 24 7) "week")))
((< seconds-difference (* 1.5 60 60 24 365))
(cons "1 year ago"
(* 60 60 24 365 1.5))) ;; a year and a half
(t
(funcall regular-response seconds-difference (* 60 60 24 365.25) "year")))))
(cons (car relative-result)
(time-add timestamp (seconds-to-time (cdr relative-result))))))
(defun mastodon-tl--relative-time-description (timestamp &optional current-time)
"Return a string with a human readable TIMESTAMP relative to the current time.
Use the optional CURRENT-TIME as the current time (only used for
reliable testing).
E.g. this could return something like \"1 min ago\", \"yesterday\", etc.
TIME-STAMP is assumed to be in the past."
(car (mastodon-tl--relative-time-details timestamp current-time)))
;;; RENDERING HTML, LINKS, HASHTAGS, HANDLES
(defun mastodon-tl--render-text (string &optional toot)
"Return a propertized text rendering the given HTML string STRING.
The contents comes from the given TOOT which is used in parsing
links in the text. If TOOT is nil no parsing occurs."
(when string ; handle rare empty notif server bug
(with-temp-buffer
(insert string)
(let ((shr-use-fonts mastodon-tl--enable-proportional-fonts)
(shr-width (when mastodon-tl--enable-proportional-fonts
(- (window-width) 3))))
(shr-render-region (point-min) (point-max)))
;; Make all links a tab stop recognized by our own logic, make things point
;; to our own logic (e.g. hashtags), and update keymaps where needed:
(when toot
(let (region)
(while (setq region (mastodon-tl--find-property-range
'shr-url (or (cdr region) (point-min))))
(mastodon-tl--process-link toot
(car region) (cdr region)
(get-text-property (car region) 'shr-url)))))
(buffer-string))))
(defun mastodon-tl--process-link (toot start end url)
"Process link URL in TOOT as hashtag, userhandle, or normal link.
START and END are the boundaries of the link in the toot."
(let* (mastodon-tab-stop-type
keymap
(help-echo (get-text-property start 'help-echo))
extra-properties
;; handle calling this on non-toots, e.g. for profiles:
(toot-url (when (proper-list-p toot)
(mastodon-tl--field 'url toot)))
(toot-url (when toot-url (url-generic-parse-url toot-url)))
(toot-instance-url (if toot-url
(concat (url-type toot-url) "://"
(url-host toot-url))
mastodon-instance-url))
(link-str (buffer-substring-no-properties start end))
(maybe-hashtag (mastodon-tl--extract-hashtag-from-url
url toot-instance-url))
(maybe-userhandle
(if (proper-list-p toot) ; fails for profile buffers?
(or (mastodon-tl--userhandle-from-mentions toot
link-str)
;; FIXME: if prev always works, cut this:
(mastodon-tl--extract-userhandle-from-url
url link-str))
(mastodon-tl--extract-userhandle-from-url
url link-str))))
(cond (;; Hashtags:
maybe-hashtag
(setq mastodon-tab-stop-type 'hashtag
keymap mastodon-tl--link-keymap
help-echo (concat "Browse tag #" maybe-hashtag)
extra-properties (list 'mastodon-tag maybe-hashtag)))
(;; User handles:
maybe-userhandle
;; this fails on mentions in profile notes:
(let ((maybe-userid (when (proper-list-p toot)
(mastodon-tl--extract-userid-toot
toot link-str))))
(setq mastodon-tab-stop-type 'user-handle
keymap mastodon-tl--link-keymap
help-echo (concat "Browse user profile of " maybe-userhandle)
extra-properties (append
(list 'mastodon-handle maybe-userhandle)
(when maybe-userid
(list 'account-id maybe-userid))))))
;; Anything else:
(t
;; Leave it as a url handled by shr.el.
;; (We still have to replace the keymap so that tabbing works.)
(setq keymap (if (eq shr-map (get-text-property start 'keymap))
mastodon-tl--shr-map-replacement
mastodon-tl--shr-image-map-replacement)
mastodon-tab-stop-type 'shr-url)))
(add-text-properties start end
(append
(list 'mastodon-tab-stop mastodon-tab-stop-type
'keymap keymap
'help-echo help-echo)
extra-properties))))
(defun mastodon-tl--userhandle-from-mentions (toot link)
"Extract a user handle from mentions in json TOOT.
LINK is maybe the '@handle' to search for."
(mastodon-tl--extract-el-from-mentions 'acct toot link))
(defun mastodon-tl--extract-userid-toot (toot link)
"Extract a user id for an ACCT from mentions in a TOOT.
LINK is maybe the '@handle' to search for."
(mastodon-tl--extract-el-from-mentions 'id toot link))
(defun mastodon-tl--extract-el-from-mentions (el toot link)
"Extract element EL from TOOT mentions that matches LINK.
LINK should be a simple handle string with no domain, i.e. @user.
Return nil if no matching element"
;; Must return nil if nothing found!
;; TODO: we should break the while loop as soon as we get sth
(let ((mentions (append (alist-get 'mentions toot) nil)))
(when mentions
(let* ((mention (pop mentions))
(name (substring-no-properties link 1 (length link))) ; cull @
return)
(while mention
(when (string= (alist-get 'username mention)
name)
(setq return (alist-get el mention)))
(setq mention (pop mentions)))
return))))
(defun mastodon-tl--extract-userhandle-from-url (url buffer-text)
"Return the user hande the URL points to or nil if it is not a profile link.
BUFFER-TEXT is the text covered by the link with URL, for a user profile
this should be of the form <at-sign><user id>, e.g. \"@Gargon\"."
(let* ((parsed-url (url-generic-parse-url url))
(local-p (string=
(url-host (url-generic-parse-url mastodon-instance-url))
(url-host parsed-url))))
(when (and (string= "@" (substring buffer-text 0 1))
(string= (downcase buffer-text)
(downcase (substring (url-filename parsed-url) 1))))
(if local-p
buffer-text ; no instance suffic for local mention
(concat buffer-text "@" (url-host parsed-url))))))
(defun mastodon-tl--extract-hashtag-from-url (url instance-url)
"Return the hashtag that URL points to or nil if URL is not a tag link.
INSTANCE-URL is the url of the instance for the toot that the link
came from (tag links always point to a page on the instance publishing
the toot)."
(cond
;; Mastodon type tag link:
((string-prefix-p (concat instance-url "/tags/") url)
(substring url (length (concat instance-url "/tags/"))))
;; Link from some other ostatus site we've encountered:
((string-prefix-p (concat instance-url "/tag/") url)
(substring url (length (concat instance-url "/tag/"))))
;; If nothing matches we assume it is not a hashtag link:
(t nil)))
;;; HYPERLINKS
(defun mastodon-tl--make-link (string link-type)
"Return a propertized version of STRING that will act like link.
LINK-TYPE is the type of link to produce."
(let ((help-text (cond
((eq link-type 'content-warning)
"Toggle hidden text")
(t
(error "Unknown link type %s" link-type)))))
(propertize
string
'mastodon-tab-stop link-type
'mouse-face 'highlight
'keymap mastodon-tl--link-keymap
'help-echo help-text)))
(defun mastodon-tl--do-link-action-at-point (position)
"Do the action of the link at POSITION.
Used for hitting RET on a given link."
(interactive "d")
(let ((link-type (get-text-property position 'mastodon-tab-stop)))
(cond ((eq link-type 'content-warning)
(mastodon-tl--toggle-spoiler-text position))
((eq link-type 'hashtag)
(mastodon-tl--show-tag-timeline nil (get-text-property position 'mastodon-tag)))
;; 'account / 'account-id is not set for mentions, only bylines
((eq link-type 'user-handle)
(let ((account-json (get-text-property position 'account))
(account-id (get-text-property position 'account-id)))
(cond
(account-json
(mastodon-profile--make-author-buffer
account-json))
(account-id
(mastodon-profile--make-author-buffer
(mastodon-profile--account-from-id account-id)))
(t
(let ((account
(mastodon-profile--search-account-by-handle
(get-text-property position 'mastodon-handle))))
;; never call make-author-buffer on nil account:
(if account
(mastodon-profile--make-author-buffer account)
;; optional webfinger lookup:
(if (y-or-n-p
"Search for account returned nothing. Perform URL lookup?")
(mastodon-url-lookup (get-text-property position 'shr-url))
(message "Unable to find account."))))))))
(t
(error "Unknown link type %s" link-type)))))
(defun mastodon-tl--do-link-action (event)
"Do the action of the link at point.
Used for a mouse-click EVENT on a link."
(interactive "e")
(mastodon-tl--do-link-action-at-point (posn-point (event-end event))))
;;; CONTENT WARNINGS
(defun mastodon-tl--has-spoiler (toot)
"Check if the given TOOT has a spoiler text.
Spoiler text should initially be shown only while the main
content should be hidden."
(let ((spoiler (mastodon-tl--field 'spoiler_text toot)))
(and spoiler (> (length spoiler) 0))))
(defun mastodon-tl--toggle-spoiler-text (position)
"Toggle the visibility of the spoiler text at/after POSITION."
(let ((inhibit-read-only t)
(spoiler-text-region (mastodon-tl--find-property-range
'mastodon-content-warning-body position nil)))
(if (not spoiler-text-region)
(message "No spoiler text here")
(add-text-properties (car spoiler-text-region) (cdr spoiler-text-region)
(list 'invisible
(not (get-text-property (car spoiler-text-region)
'invisible)))))))
(defun mastodon-tl--toggle-spoiler-text-in-toot ()
"Toggle the visibility of the spoiler text in the current toot."
(interactive)
(let* ((toot-range (or (mastodon-tl--find-property-range
'toot-json (point))
(mastodon-tl--find-property-range
'toot-json (point) t)))
(spoiler-range (when toot-range
(mastodon-tl--find-property-range
'mastodon-content-warning-body
(car toot-range)))))
(cond ((null toot-range)
(message "No toot here"))
((or (null spoiler-range)
(> (car spoiler-range) (cdr toot-range)))
(message "No content warning text here"))
(t
(mastodon-tl--toggle-spoiler-text (car spoiler-range))))))
(defun mastodon-tl--clean-tabs-and-nl (string)
"Remove tabs and newlines from STRING."
(replace-regexp-in-string
"[\t\n ]*\\'" "" string))
(defun mastodon-tl--spoiler (toot)
"Render TOOT with spoiler message.
This assumes TOOT is a toot with a spoiler message.
The main body gets hidden and only the spoiler text and the
content warning message are displayed. The content warning
message is a link which unhides/hides the main body."
(let* ((spoiler (mastodon-tl--field 'spoiler_text toot))
(string (mastodon-tl--set-face
;; remove trailing whitespace
(mastodon-tl--clean-tabs-and-nl
(mastodon-tl--render-text spoiler toot))
'default))
(message (concat
" " mastodon-tl--horiz-bar "\n "
(mastodon-tl--make-link
(concat "CW: " string)
'content-warning)
"\n "
mastodon-tl--horiz-bar "\n"))
(cw (mastodon-tl--set-face message 'mastodon-cw-face)))
(concat
cw
(propertize (mastodon-tl--content toot)
'invisible
;; check server setting to expand all spoilers:
(unless (eq t
;; If something goes wrong reading prefs,
;; just return nil so CWs show by default.
(condition-case nil
(mastodon-profile--get-preferences-pref
'reading:expand:spoilers)
(error nil)))
t)
'mastodon-content-warning-body t))))
;;; MEDIA
(defun mastodon-tl--media (toot)
"Retrieve a media attachment link for TOOT if one exists."
(let* ((media-attachments (mastodon-tl--field 'media_attachments toot))
(media-string (mapconcat #'mastodon-tl--media-attachment
media-attachments "")))
(if (not (and mastodon-tl--display-media-p
(string-empty-p media-string)))
(concat "\n" media-string)
"")))
(defun mastodon-tl--media-attachment (media-attachment)
"Return a propertized string for MEDIA-ATTACHMENT."
(let* ((preview-url (alist-get 'preview_url media-attachment))
(remote-url
(or (alist-get 'remote_url media-attachment)
;; fallback b/c notifications don't have remote_url
(alist-get 'url media-attachment)))
(type (alist-get 'type media-attachment))
(caption (alist-get 'description media-attachment))
(display-str
(if (and mastodon-tl--display-caption-not-url-when-no-media
caption)
(concat "Media:: " caption)
(concat "Media:: " preview-url))))
(if mastodon-tl--display-media-p
;; return placeholder [img]:
(mastodon-media--get-media-link-rendering
preview-url remote-url type caption) ; 2nd arg for shr-browse-url
;; return URL/caption:
(concat (mastodon-tl--propertize-img-str-or-url
(concat "Media:: " preview-url) ;; string
preview-url remote-url type caption
display-str ;; display
;; FIXME: shr-link underlining is awful for captions with
;; newlines, as the underlining runs to the edge of the
;; frame even if the text doesn'
'shr-link)
"\n"))))
(defun mastodon-tl--propertize-img-str-or-url (str media-url full-remote-url
type help-echo
&optional display face)
"Propertize an media placeholder string \"[img]\" or media URL.
STR is the string to propertize, MEDIA-URL is the preview link,
FULL-REMOTE-URL is the link to the full resolution image on the
server, TYPE is the media type.
HELP-ECHO, DISPLAY, and FACE are the text properties to add."
(propertize str
'media-url media-url
'media-state (when (string= str "[img]") 'needs-loading)
'media-type 'media-link
'mastodon-media-type type
'display display
'face face
'mouse-face 'highlight
'mastodon-tab-stop 'image ; for do-link-action-at-point
'image-url full-remote-url ; for shr-browse-image
'keymap mastodon-tl--shr-image-map-replacement
'help-echo (if (or (string= type "image")
(string= type nil)
(string= type "unknown")) ;handle borked images
help-echo
(concat help-echo "\nC-RET: play " type " with mpv"))))
;; POLLS
(defun mastodon-tl--get-poll (toot)
"If TOOT includes a poll, return it as a formatted string."
(let* ((poll (mastodon-tl--field 'poll toot))
(expiry (mastodon-tl--field 'expires_at poll))
(expired-p (if (eq (mastodon-tl--field 'expired poll) :json-false) nil t))
;; (multi (mastodon-tl--field 'multiple poll))
(voters-count (mastodon-tl--field 'voters_count poll))
(vote-count (mastodon-tl--field 'votes_count poll))
(options (mastodon-tl--field 'options poll))
(option-titles (mastodon-tl--map-alist 'title options))
(longest-option (car (sort option-titles
(lambda (x y)
(> (length x)
(length y))))))
(option-counter 0))
(concat "\nPoll: \n\n"
(mapconcat (lambda (option)
(progn
(format "%s: %s%s%s\n"
(setq option-counter (1+ option-counter))
(propertize (alist-get 'title option)
'face 'success)
(make-string
(1+
(- (length longest-option)
(length (alist-get 'title
option))))
?\ )
;; TODO: disambiguate no votes from hidden votes
(format "[%s votes]" (or (alist-get 'votes_count option)
"0")))))
options
"\n")
"\n"
(propertize
(cond (voters-count ; sometimes it is nil
(if (= voters-count 1)
(format "%s person | " voters-count)
(format "%s people | " voters-count)))
(vote-count
(format "%s votes | " vote-count))
(t
""))
'face 'font-lock-comment-face)
(let ((str (if expired-p
"Poll expired."
(mastodon-tl--format-poll-expiry expiry))))
(propertize str 'face 'font-lock-comment-face))
"\n")))
(defun mastodon-tl--format-poll-expiry (timestamp)
"Convert poll expiry TIMESTAMP into a descriptive string."
(let ((parsed (ts-human-duration
(ts-diff (ts-parse timestamp) (ts-now)))))
(cond ((> (plist-get parsed :days) 0)
(format "%s days, %s hours left"
(plist-get parsed :days)
(plist-get parsed :hours)))
((> (plist-get parsed :hours) 0)
(format "%s hours, %s minutes left"
(plist-get parsed :hours)
(plist-get parsed :minutes)))
((> (plist-get parsed :minutes) 0)
(format "%s minutes left" (plist-get parsed :minutes)))
(t ;; we failed to guess:
(format "%s days, %s hours, %s minutes left"
(plist-get parsed :days)
(plist-get parsed :hours)
(plist-get parsed :minutes))))))
(defun mastodon-tl--poll-vote (option)
"If there is a poll at point, prompt user for OPTION to vote on it."
(interactive
(list
(let* ((toot (mastodon-tl--property 'toot-json))
(reblog (alist-get 'reblog toot))
(poll (or (alist-get 'poll reblog)
(mastodon-tl--field 'poll toot)))
(options (mastodon-tl--field 'options poll))
(options-titles (mastodon-tl--map-alist 'title options))
(options-number-seq (number-sequence 1 (length options)))
(options-numbers (mapcar #'number-to-string options-number-seq))
(options-alist (cl-mapcar #'cons options-numbers options-titles))
;; we display both option number and the option title
;; but also store both as cons cell as cdr, as we need it below
(candidates (mapcar (lambda (cell)
(cons (format "%s | %s" (car cell) (cdr cell))
cell))
options-alist)))
(if (null (mastodon-tl--field 'poll (mastodon-tl--property 'toot-json)))
(message "No poll here.")
;; var "option" = just the cdr, a cons of option number and desc
(cdr (assoc
(completing-read "Poll option to vote for: "
candidates
nil ; (predicate)
t) ; require match
candidates))))))
(if (null (mastodon-tl--field 'poll (mastodon-tl--property 'toot-json)))
(message "No poll here.")
(let* ((toot (mastodon-tl--property 'toot-json))
(poll (mastodon-tl--field 'poll toot))
(poll-id (alist-get 'id poll))
(url (mastodon-http--api (format "polls/%s/votes" poll-id)))
;; need to zero-index our option:
(option-as-arg (number-to-string (1- (string-to-number (car option)))))
(arg `(("choices[]" . ,option-as-arg)))
(response (mastodon-http--post url arg)))
(mastodon-http--triage response
(lambda ()
(message "You voted for option %s: %s!"
(car option) (cdr option)))))))
;; VIDEOS / MPV
(defun mastodon-tl--find-first-video-in-attachments ()
"Return the first media attachment that is a moving image."
(let ((attachments (mastodon-tl--property 'attachments))
vids)
(mapc (lambda (x)
(let ((att-type (plist-get x :type)))
(when (or (string= "video" att-type)
(string= "gifv" att-type))
(push x vids))))
attachments)
(car vids)))
(defun mastodon-tl--mpv-play-video-from-byline ()
"Run `mastodon-tl--mpv-play-video-at-point' on first moving image in post."
(interactive)
(let* ((video (mastodon-tl--find-first-video-in-attachments))
(url (plist-get video :url))
(type (plist-get video :type)))
(mastodon-tl--mpv-play-video-at-point url type)))
(defun mastodon-tl--mpv-play-video-at-point (&optional url type)
"Play the video or gif at point with an mpv process.
URL and TYPE are provided when called while point is on byline,
in which case play first video or gif from current toot."
(interactive)
(let ((url (or
;; point in byline:
url
;; point in toot:
(mastodon-tl--property 'image-url :no-move)))
(type (or ;; in byline:
type
;; point in toot:
(mastodon-tl--property 'mastodon-media-type :no-move))))
(if url
(if (or (equal type "gifv")
(equal type "video"))
(progn
(message "'q' to kill mpv.")
(mpv-start "--loop" url))
(message "no moving image here?"))
(message "no moving image here?"))))
;;; INSERT TOOTS
(defun mastodon-tl--content (toot)
"Retrieve text content from TOOT.
Runs `mastodon-tl--render-text' and fetches poll or media."
(let* ((content (mastodon-tl--field 'content toot))
(reblog (alist-get 'reblog toot))
(poll-p (if reblog
(alist-get 'poll reblog)
(alist-get 'poll toot))))
(concat
(mastodon-tl--render-text content toot)
(when poll-p
(mastodon-tl--get-poll toot))
(mastodon-tl--media toot))))
(defun mastodon-tl--prev-toot-id ()
"Return the id of the last toot inserted into the buffer."
(let ((prev-pos (1- (save-excursion
(previous-single-property-change
(point)
'base-toot-id)))))
(get-text-property prev-pos 'base-toot-id)))
(defun mastodon-tl--after-reply-status (reply-to-id)
"T if REPLY-TO-ID is equal to that of the last toot inserted in the bufer."
(let ((prev-id (mastodon-tl--prev-toot-id)))
(string= reply-to-id prev-id)))
(defun mastodon-tl--insert-status (toot body author-byline action-byline
&optional id base-toot detailed-p thread)
"Display the content and byline of timeline element TOOT.
BODY will form the section of the toot above the byline.
AUTHOR-BYLINE is an optional function for adding the author
portion of the byline that takes one variable. By default it is
`mastodon-tl--byline-author'.
ACTION-BYLINE is also an optional function for adding an action,
such as boosting favouriting and following to the byline. It also
takes a single function. By default it is
`mastodon-tl--byline-boosted'.
ID is that of the status if it is a notification, which is
attached as a `toot-id' property if provided. If the
status is a favourite or boost notification, BASE-TOOT is the
JSON of the toot responded to.
DETAILED-P means display more detailed info. For now
this just means displaying toot client.
THREAD means the status will be displayed in a thread view."
(let* ((start-pos (point))
(reply-to-id (alist-get 'in_reply_to_id toot))
(after-reply-status-p
(when (and thread reply-to-id)
(mastodon-tl--after-reply-status reply-to-id))))
(insert
(propertize
(concat
"\n"
(if (and after-reply-status-p thread)
(concat (mastodon-tl--symbol 'replied)
"\n")
"")
(if (and after-reply-status-p thread)
(let ((bar (mastodon-tl--symbol 'reply-bar)))
(propertize body
'line-prefix bar
'wrap-prefix bar))
body)
;; body
" \n"
(mastodon-tl--byline toot author-byline action-byline detailed-p))
'toot-id (or id ; notification's own id
(alist-get 'id toot)) ; toot id
'base-toot-id (mastodon-tl--toot-id
;; if status is a notif, get id from base-toot
;; (-tl--toot-id toot) will not work here:
(or base-toot
;; else normal toot with reblog check:
toot))
'toot-json toot
'base-toot base-toot)
"\n")
(when mastodon-tl--display-media-p
(mastodon-media--inline-images start-pos (point)))))
;; from mastodon-alt.el:
(defun mastodon-tl--toot-for-stats (&optional toot)
"Return the TOOT on which we want to extract stats.
If no TOOT is given, the one at point is considered."
(let* ((original-toot (or toot (get-text-property (point) 'toot-json)))
(toot (or (alist-get 'status original-toot)
(when (alist-get 'type original-toot)
original-toot)
(alist-get 'reblog original-toot)
original-toot))
(type (alist-get 'type (or toot))))
(unless (member type '("follow" "follow_request"))
toot)))
(defun mastodon-tl--toot-stats (toot)
"Return a right aligned string (using display align-to).
String is filled with TOOT statistics (boosts, favs, replies).
When the TOOT is a reblog (boost), statistics from reblogged
toots are returned.
To disable showing the stats, customize
`mastodon-tl--show-stats'."
(when-let ((toot (mastodon-tl--toot-for-stats toot)))
(let* ((favourites-count (alist-get 'favourites_count toot))
(favourited (equal 't (alist-get 'favourited toot)))
(faves-prop (propertize (format "%s" favourites-count)
'favourites-count favourites-count))
(boosts-count (alist-get 'reblogs_count toot))
(boosted (equal 't (alist-get 'reblogged toot)))
(boosts-prop (propertize (format "%s" boosts-count)
'boosts-count boosts-count))
(replies-count (alist-get 'replies_count toot))
(favourites (format "%s %s" faves-prop ;favourites-count
(mastodon-tl--symbol 'favourite)))
(boosts (format "%s %s" boosts-prop ;boosts-count
(mastodon-tl--symbol 'boost)))
(replies (format "%s %s" replies-count (mastodon-tl--symbol 'reply)))
(status (concat
(propertize favourites
'favourited-p favourited
'favourites-field t
'help-echo (format "%s favourites" favourites-count)
'face font-lock-comment-face)
(propertize " | " 'face font-lock-comment-face)
(propertize boosts
'boosted-p boosted
'boosts-field t
'help-echo (format "%s boosts" boosts-count)
'face font-lock-comment-face)
(propertize " | " 'face font-lock-comment-face)
(propertize replies
'replies-field t
'replies-count replies-count
'help-echo (format "%s replies" replies-count)
'face font-lock-comment-face)))
(status
(concat
(propertize " "
'display
`(space :align-to (- right ,(+ (length status) 7))))
status)))
status)))
(defun mastodon-tl--is-reply (toot)
"Check if the TOOT is a reply to another one (and not boosted)."
(and (null (mastodon-tl--field 'in_reply_to_id toot))
(not (mastodon-tl--field 'rebloged toot))))
(defun mastodon-tl--toot (toot &optional detailed-p thread)
"Format TOOT and insert it into the buffer.
DETAILED-P means display more detailed info. For now
this just means displaying toot client.
THREAD means the status will be displayed in a thread view."
(mastodon-tl--insert-status
toot
(mastodon-tl--clean-tabs-and-nl
(if (mastodon-tl--has-spoiler toot)
(mastodon-tl--spoiler toot)
(mastodon-tl--content toot)))
'mastodon-tl--byline-author
'mastodon-tl--byline-boosted
nil
nil
detailed-p
thread))
(defun mastodon-tl--timeline (toots &optional thread)
"Display each toot in TOOTS.
This function removes replies if user required.
THREAD means the status will be displayed in a thread view."
(mapc (lambda (toot)
(mastodon-tl--toot toot nil thread))
;; hack to *not* filter replies on profiles:
(if (eq (mastodon-tl--get-buffer-type) 'profile-statuses)
toots
(if (or ; we were called via --more*:
(mastodon-tl--get-buffer-property 'hide-replies nil :no-error)
;; loading a tl with a prefix arg:
(mastodon-tl--hide-replies-p current-prefix-arg))
(cl-remove-if-not #'mastodon-tl--is-reply toots)
toots)))
(goto-char (point-min)))
;;; BUFFER SPEC
(defun mastodon-tl--get-update-function (&optional buffer)
"Get the UPDATE-FUNCTION stored in `mastodon-tl--buffer-spec'.
Optionally get it for BUFFER."
(mastodon-tl--get-buffer-property 'update-function buffer))
(defun mastodon-tl--get-endpoint (&optional buffer no-error)
"Get the ENDPOINT stored in `mastodon-tl--buffer-spec'.
Optionally set it for BUFFER.
NO-ERROR means to fail silently."
(mastodon-tl--get-buffer-property 'endpoint buffer no-error))
(defun mastodon-tl--buffer-name (&optional buffer no-error)
"Get the BUFFER-NAME stored in `mastodon-tl--buffer-spec'.
Optionally get it for BUFFER.
NO-ERROR means to fail silently."
(mastodon-tl--get-buffer-property 'buffer-name buffer no-error))
(defun mastodon-tl--link-header (&optional buffer)
"Get the LINK HEADER stored in `mastodon-tl--buffer-spec'.
Optionally get it for BUFFER."
(mastodon-tl--get-buffer-property 'link-header buffer :no-error))
(defun mastodon-tl--update-params (&optional buffer)
"Get the UPDATE PARAMS stored in `mastodon-tl--buffer-spec'.
Optionally get it for BUFFER."
(mastodon-tl--get-buffer-property 'update-params buffer :no-error))
(defun mastodon-tl--get-buffer-property (property &optional buffer no-error)
"Get PROPERTY from `mastodon-tl--buffer-spec' in BUFFER or `current-buffer'.
If NO-ERROR is non-nil, do not error when property is empty."
(with-current-buffer (or buffer (current-buffer))
(if no-error
(plist-get mastodon-tl--buffer-spec property)
(or (plist-get mastodon-tl--buffer-spec property)
(error "Mastodon-tl--buffer-spec is not defined for buffer %s"
(or buffer (current-buffer)))))))
(defun mastodon-tl--set-buffer-spec (buffer endpoint update-function
&optional link-header update-params
hide-replies)
"Set `mastodon-tl--buffer-spec' for the current buffer.
BUFFER is buffer name, ENDPOINT is buffer's enpoint,
UPDATE-FUNCTION is its update function.
LINK-HEADER is the http Link header if present.
UPDATE-PARAMS is any http parameters needed for the update function.
HIDE-REPLIES is a flag indicating if replies are hidden in the current buffer."
(setq mastodon-tl--buffer-spec
`(account ,(cons mastodon-active-user
mastodon-instance-url)
buffer-name ,buffer
endpoint ,endpoint
update-function ,update-function
link-header ,link-header
update-params ,update-params
hide-replies ,hide-replies)))
;;; BUFFERS
(defun mastodon-tl--endpoint-str-= (str &optional type)
"Return T if STR is equal to the current buffer's endpoint.
TYPE may be :prefix or :suffix, in which case, T if STR is a prefix or suffix."
(let ((endpoint-fun (mastodon-tl--get-endpoint nil :no-error)))
(cond ((eq type :prefix)
(string-prefix-p str endpoint-fun))
((eq type :suffix)
(string-suffix-p str endpoint-fun))
(t
(string= str endpoint-fun)))))
(defun mastodon-tl--get-buffer-type ()
"Return a symbol descriptive of current mastodon buffer type.
Should work in all mastodon buffers.
Note that for many buffers, this requires `mastodon-tl--buffer-spec'
to be set. It is set for almost all buffers, but you still have to
call this function after it is set or use something else."
(let ((buffer-name (mastodon-tl--buffer-name nil :no-error)))
(cond (mastodon-toot-mode
;; composing/editing:
(if (string= "*edit toot*" (buffer-name))
'edit-toot
'new-toot))
;; main timelines:
((mastodon-tl--endpoint-str-= "timelines/home")
'home)
((string= "*mastodon-local*" buffer-name)
'local)
((mastodon-tl--endpoint-str-= "timelines/public")
'federated)
((mastodon-tl--endpoint-str-= "timelines/tag/" :prefix)
'tag-timeline)
((mastodon-tl--endpoint-str-= "timelines/list/" :prefix)
'list-timeline)
;; notifs:
((string-suffix-p "mentions*" buffer-name)
'mentions)
((mastodon-tl--endpoint-str-= "notifications")
'notifications)
;; threads:
((mastodon-tl--endpoint-str-= "context" :suffix)
'thread)
((mastodon-tl--endpoint-str-= "statuses" :prefix)
'single-status)
;; profiles:
((mastodon-tl--profile-buffer-p)
(cond
;; own profile:
;; perhaps not needed, and needlessly confusing,
;; e.g. for `mastodon-profile--account-view-cycle':
;; ((equal (mastodon-tl--buffer-name)
;; (concat "*mastodon-" (mastodon-auth--get-account-name) "-statuses*"))
;; 'own-profile-statuses)
;; profile note:
((string-suffix-p "update-profile*" buffer-name)
'update-profile-note)
;; posts inc. boosts:
((string-suffix-p "no-boosts*" buffer-name)
'profile-statuses-no-boosts)
((mastodon-tl--endpoint-str-= "statuses" :suffix)
'profile-statuses)
;; profile followers
((mastodon-tl--endpoint-str-= "followers" :suffix)
'profile-followers)
;; profile following
((mastodon-tl--endpoint-str-= "following" :suffix)
'profile-following)))
((mastodon-tl--endpoint-str-= "preferences")
'preferences)
;; search
((mastodon-tl--endpoint-str-= "search" :suffix)
'search)
;; trends
((mastodon-tl--endpoint-str-= "api/v1/trends/statuses")
'trending-statuses)
((mastodon-tl--endpoint-str-= "api/v1/trends/tags")
'trending-tags)
((mastodon-tl--endpoint-str-= "api/v1/trends/links")
'trending-links)
;; User's views:
((mastodon-tl--endpoint-str-= "filters")
'filters)
((mastodon-tl--endpoint-str-= "lists")
'lists)
((mastodon-tl--endpoint-str-= "suggestions")
'follow-suggestions)
((mastodon-tl--endpoint-str-= "favourites")
'favourites)
((mastodon-tl--endpoint-str-= "bookmarks")
'bookmarks)
((mastodon-tl--endpoint-str-= "follow_requests")
'follow-requests)
((mastodon-tl--endpoint-str-= "scheduled_statuses")
'scheduled-statuses)
;; instance description
((mastodon-tl--endpoint-str-= "instance")
'instance-description)
((string= "*mastodon-toot-edits*" buffer-name)
'toot-edits))))
(defun mastodon-tl--buffer-type-eq (type)
"Return t if current buffer type is equal to symbol TYPE."
(eq (mastodon-tl--get-buffer-type) type))
(defun mastodon-tl--profile-buffer-p ()
"Return t if current buffer is a profile buffer of any kind.
This includes the update profile note buffer, but not the preferences one."
(string-prefix-p "accounts" (mastodon-tl--get-endpoint nil :no-error)))
(defun mastodon-tl--timeline-proper-p ()
"Return non-nil if the current buffer is a 'proper' timeline.
A proper timeline excludes notifications, threads, and other toot
buffers that aren't strictly mastodon timelines."
(let ((timeline-buffers
'(home federated local tag-timeline list-timeline profile-statuses)))
(member (mastodon-tl--get-buffer-type) timeline-buffers)))
(defun mastodon-tl--hide-replies-p (&optional prefix)
"Return non-nil if replies should be hidden in the timeline.
We hide replies if user explictly set the
`mastodon-tl--hide-replies' or used PREFIX combination to open a
timeline."
(and (mastodon-tl--timeline-proper-p) ; Only if we are in a proper timeline
(or mastodon-tl--hide-replies ; User configured to hide replies
(equal '(4) prefix)))) ; Timeline called with C-u prefix
;;; UTILITIES
(defun mastodon-tl--map-alist (key alist)
"Return a list of values extracted from ALIST with KEY.
Key is a symbol, as with `alist-get'."
(mapcar (lambda (x)
(alist-get key x))
alist))
(defun mastodon-tl--map-alist-vals-to-alist (key1 key2 alist)
"From ALIST, return an alist consisting of (val1 . val2) elements.
Values are accessed by `alist-get', using KEY1 and KEY2."
(mapcar (lambda (x)
(cons (alist-get key1 x)
(alist-get key2 x)))
alist))
(defun mastodon-tl--symbol (name)
"Return the unicode symbol (as a string) corresponding to NAME.
If symbol is not displayable, an ASCII equivalent is returned. If
NAME is not part of the symbol table, '?' is returned."
(if-let* ((symbol (alist-get name mastodon-tl--symbols)))
(if (char-displayable-p (string-to-char (car symbol)))
(car symbol)
(cdr symbol))
"?"))
(defun mastodon-tl--set-face (string face)
"Return the propertized STRING with the face property set to FACE."
(propertize string 'face face))
(defun mastodon-tl--field (field toot)
"Return FIELD from TOOT.
Return value from boosted content if available."
(or (alist-get field (alist-get 'reblog toot))
(alist-get field toot)))
(defun mastodon-tl--remove-html (toot)
"Remove unrendered tags from TOOT."
(let* ((t1 (replace-regexp-in-string "<\/p>" "\n\n" toot))
(t2 (replace-regexp-in-string "<\/?span>" "" t1)))
(replace-regexp-in-string "<span class=\"h-card\">" "" t2)))
(defun mastodon-tl--property (prop &optional no-move backward)
"Get property PROP for toot at point.
Move forward (down) the timeline unless NO-MOVE is non-nil.
BACKWARD means move backward (up) the timeline."
(if no-move
(get-text-property (point) prop)
(or (get-text-property (point) prop)
(save-excursion
(if backward
(mastodon-tl--goto-prev-toot)
(mastodon-tl--goto-next-toot))
(get-text-property (point) prop)))))
(defun mastodon-tl--newest-id ()
"Return toot-id from the top of the buffer."
(save-excursion
(goto-char (point-min))
(mastodon-tl--property 'toot-id)))
(defun mastodon-tl--oldest-id ()
"Return toot-id from the bottom of the buffer."
(save-excursion
(goto-char (point-max))
(mastodon-tl--property 'toot-id nil :backward)))
(defun mastodon-tl--as-string (numeric)
"Convert NUMERIC to string."
(cond ((numberp numeric)
(number-to-string numeric))
((stringp numeric) numeric)
(t (error
"Numeric:%s must be either a string or a number"
numeric))))
(defun mastodon-tl--toot-id (json)
"Find approproiate toot id in JSON.
If the toot has been boosted use the id found in the
reblog portion of the toot. Otherwise, use the body of
the toot. This is the same behaviour as the mastodon.social
webapp"
(let ((id (alist-get 'id json))
(reblog (alist-get 'reblog json)))
(if reblog (alist-get 'id reblog) id)))
(defun mastodon-tl--toot-or-base (json)
"Return the base toot or just the toot from toot JSON."
(or (alist-get 'reblog json) json))
;;; THREADS
(defun mastodon-tl--single-toot (id)
"View toot at point in separate buffer.
ID is that of the toot to view."
(interactive)
(let* ((buffer (format "*mastodon-toot-%s*" id))
(toot (mastodon-http--get-json
(mastodon-http--api (concat "statuses/" id)))))
(if (equal (caar toot) 'error)
(message "Error: %s" (cdar toot))
(with-mastodon-buffer buffer #'mastodon-mode nil
(mastodon-tl--set-buffer-spec buffer (format "statuses/%s" id) nil)
(mastodon-tl--toot toot :detailed-p)))))
(defun mastodon-tl--view-whole-thread ()
"From a thread view, view entire thread.
If you load a thread from a toot, only the branches containing
are displayed by default. Call this if you subsequently want to
view all branches of a thread."
(interactive)
(if (not (eq (mastodon-tl--get-buffer-type) 'thread))
(error "You need to be viewing a thread to call this")
(goto-char (point-min))
(let ((id (mastodon-tl--property 'base-toot-id)))
(mastodon-tl--thread id))))
(defun mastodon-tl--thread (&optional id)
"Open thread buffer for toot at point or with ID."
(interactive)
(let* ((id (or id (mastodon-tl--property 'base-toot-id :no-move)))
(type (mastodon-tl--field 'type (mastodon-tl--property 'toot-json :no-move))))
(if (or (string= type "follow_request")
(string= type "follow")) ; no can thread these
(error "No thread")
(let* ((endpoint (format "statuses/%s/context" id))
(url (mastodon-http--api endpoint))
(buffer (format "*mastodon-thread-%s*" id))
(toot
;; refetch current toot in case we just faved/boosted:
(mastodon-http--get-json
(mastodon-http--api (concat "statuses/" id))
nil
:silent))
(context (mastodon-http--get-json url nil :silent)))
(if (equal (caar toot) 'error)
(message "Error: %s" (cdar toot))
(when (member (alist-get 'type toot) '("reblog" "favourite"))
(setq toot (alist-get 'status toot)))
(if (> (+ (length (alist-get 'ancestors context))
(length (alist-get 'descendants context)))
0)
;; if we have a thread:
(with-mastodon-buffer buffer #'mastodon-mode nil
(let ((marker (make-marker)))
(mastodon-tl--set-buffer-spec buffer
endpoint
#'mastodon-tl--thread)
(mastodon-tl--timeline (alist-get 'ancestors context) :thread)
(goto-char (point-max))
(move-marker marker (point))
;; print re-fetched toot:
(mastodon-tl--toot toot :detailed-p :thread)
(mastodon-tl--timeline (alist-get 'descendants context)
:thread)
;; put point at the toot:
(goto-char (marker-position marker))
(mastodon-tl--goto-next-toot)))
;; else just print the lone toot:
(mastodon-tl--single-toot id)))))))
(defun mastodon-tl--mute-thread ()
"Mute the thread displayed in the current buffer.
Note that you can only (un)mute threads you have posted in."
(interactive)
(mastodon-tl--mute-or-unmute-thread))
(defun mastodon-tl--unmute-thread ()
"Mute the thread displayed in the current buffer.
Note that you can only (un)mute threads you have posted in."
(interactive)
(mastodon-tl--mute-or-unmute-thread :unmute))
(defun mastodon-tl--mute-or-unmute-thread (&optional unmute)
"Mute a thread.
If UNMUTE, unmute it."
(let ((endpoint (mastodon-tl--get-endpoint)))
(if (mastodon-tl--buffer-type-eq 'thread)
(let* ((id
(save-match-data
(string-match "statuses/\\(?2:[[:digit:]]+\\)/context"
endpoint)
(match-string 2 endpoint)))
(we-posted-p (mastodon-tl--user-in-thread-p id))
(url (mastodon-http--api
(if unmute
(format "statuses/%s/unmute" id)
(format "statuses/%s/mute" id)))))
(if (not we-posted-p)
(message "You can only (un)mute a thread you have posted in.")
(when (if unmute
(y-or-n-p "Unnute this thread? ")
(y-or-n-p "Mute this thread? "))
(let ((response (mastodon-http--post url)))
(mastodon-http--triage response
(lambda ()
(if unmute
(message "Thread unmuted!")
(message "Thread muted!")))))))))))
(defun mastodon-tl--map-account-id-from-toot (statuses)
"Return a list of the account IDs of the author of each toot in STATUSES."
(mapcar (lambda (status)
(alist-get 'id
(alist-get 'account status)))
statuses))
(defun mastodon-tl--user-in-thread-p (id)
"Return non-nil if the logged-in user has posted to the current thread.
ID is that of the post the context is currently displayed for."
(let* ((context-json (mastodon-http--get-json
(mastodon-http--api (format "statuses/%s/context" id))
nil :silent))
(ancestors (alist-get 'ancestors context-json))
(descendants (alist-get 'descendants context-json))
(a-ids (mastodon-tl--map-account-id-from-toot ancestors))
(d-ids (mastodon-tl--map-account-id-from-toot descendants)))
(or (member (mastodon-auth--get-account-id) a-ids)
(member (mastodon-auth--get-account-id) d-ids))))
;;; FOLLOW/BLOCK/MUTE, ETC
(defmacro mastodon-tl--do-if-toot (&rest body)
"Execute BODY if we have a toot or user at point."
(declare (debug t))
`(if (and (not (mastodon-tl--profile-buffer-p))
(not (mastodon-tl--property 'toot-json))) ; includes user listings
(message "Looks like there's no toot or user at point?")
,@body))
(defmacro mastodon-tl--do-if-toot-strict (&rest body)
"Execute BODY if we have a toot, and only a toot, at point."
(declare (debug t))
`(if (not (mastodon-tl--property 'toot-id :no-move))
(message "Looks like there's no toot at point?")
,@body))
(defun mastodon-tl--follow-user (user-handle &optional notify langs)
"Query for USER-HANDLE from current status and follow that user.
If NOTIFY is \"true\", enable notifications when that user posts.
If NOTIFY is \"false\", disable notifications when that user posts.
Can be called to toggle NOTIFY on users already being followed.
LANGS is an array parameters alist of languages to filer user's posts by."
(interactive
(list
(mastodon-tl--interactive-user-handles-get "follow")))
(mastodon-tl--do-if-toot
(mastodon-tl--do-user-action-and-response
user-handle "follow" nil notify langs)))
(defun mastodon-tl--enable-notify-user-posts (user-handle)
"Query for USER-HANDLE and enable notifications when they post."
(interactive
(list
(mastodon-tl--interactive-user-handles-get "enable")))
(mastodon-tl--do-if-toot
(mastodon-tl--follow-user user-handle "true")))
(defun mastodon-tl--disable-notify-user-posts (user-handle)
"Query for USER-HANDLE and disable notifications when they post."
(interactive
(list
(mastodon-tl--interactive-user-handles-get "disable")))
(mastodon-tl--follow-user user-handle "false"))
(defun mastodon-tl--filter-user-user-posts-by-language (user-handle)
"Query for USER-HANDLE and enable notifications when they post.
This feature is experimental and for now not easily varified by
the instance API."
(interactive
(list
(mastodon-tl--interactive-user-handles-get "filter by language")))
(let ((langs (mastodon-tl--read-filter-langs)))
(mastodon-tl--do-if-toot
(mastodon-tl--follow-user user-handle nil langs))))
(defun mastodon-tl--read-filter-langs (&optional langs)
"Read language choices and return an alist array parameter.
LANGS is the accumulated array param alist if we re-run recursively."
(let* ((langs-alist langs)
(choice (completing-read "Filter user's posts by language: "
mastodon-iso-639-1)))
(when choice
(setq langs-alist
(push `("languages[]" . ,(alist-get choice mastodon-iso-639-1
nil nil
#'string=))
langs-alist))
(if (y-or-n-p "Filter by another language? ")
(mastodon-tl--read-filter-langs langs-alist)
langs-alist))))
(defun mastodon-tl--unfollow-user (user-handle)
"Query for USER-HANDLE from current status and unfollow that user."
(interactive
(list
(mastodon-tl--interactive-user-handles-get "unfollow")))
(mastodon-tl--do-if-toot
(mastodon-tl--do-user-action-and-response user-handle "unfollow" t)))
(defun mastodon-tl--block-user (user-handle)
"Query for USER-HANDLE from current status and block that user."
(interactive
(list
(mastodon-tl--interactive-user-handles-get "block")))
(mastodon-tl--do-if-toot
(mastodon-tl--do-user-action-and-response user-handle "block")))
(defun mastodon-tl--unblock-user (user-handle)
"Query for USER-HANDLE from list of blocked users and unblock that user."
(interactive
(list
(mastodon-tl--interactive-blocks-or-mutes-list-get "unblock")))
(if (not user-handle)
(message "Looks like you have no blocks to unblock!")
(mastodon-tl--do-user-action-and-response user-handle "unblock" t)))
(defun mastodon-tl--mute-user (user-handle)
"Query for USER-HANDLE from current status and mute that user."
(interactive
(list
(mastodon-tl--interactive-user-handles-get "mute")))
(mastodon-tl--do-if-toot
(mastodon-tl--do-user-action-and-response user-handle "mute")))
(defun mastodon-tl--unmute-user (user-handle)
"Query for USER-HANDLE from list of muted users and unmute that user."
(interactive
(list
(mastodon-tl--interactive-blocks-or-mutes-list-get "unmute")))
(if (not user-handle)
(message "Looks like you have no mutes to unmute!")
(mastodon-tl--do-user-action-and-response user-handle "unmute" t)))
(defun mastodon-tl--dm-user (user-handle)
"Query for USER-HANDLE from current status and compose a message to that user."
(interactive
(list
(mastodon-tl--interactive-user-handles-get "message")))
(mastodon-tl--do-if-toot
(mastodon-toot--compose-buffer (concat "@" user-handle))
(setq mastodon-toot--visibility "direct")
(mastodon-toot--update-status-fields)))
(defun mastodon-tl--interactive-user-handles-get (action)
"Get the list of user-handles for ACTION from the current toot."
(mastodon-tl--do-if-toot
(let ((user-handles
(cond ((or (mastodon-tl--buffer-type-eq 'follow-suggestions)
;; follow suggests / search / foll requests compat:
(mastodon-tl--buffer-type-eq 'search)
(mastodon-tl--buffer-type-eq 'follow-requests)
;; profile view follows/followers compat:
;; but not for profile statuses:
;; fetch 'toot-json:
(mastodon-tl--buffer-type-eq 'profile-followers)
(mastodon-tl--buffer-type-eq 'profile-following))
(list (alist-get 'acct
(mastodon-tl--property 'toot-json :no-move))))
;; profile view, no toots
;; needed for e.g. gup.pe groups which show no toots publically:
((mastodon-tl--profile-buffer-p)
(list (alist-get 'acct
(mastodon-profile--profile-json))))
(t
(mastodon-profile--extract-users-handles
(mastodon-profile--toot-json))))))
;; return immediately if only 1 handle:
(if (eq 1 (length user-handles))
(car user-handles)
(completing-read (if (or (equal action "disable")
(equal action "enable"))
(format "%s notifications when user posts: " action)
(format "Handle of user to %s: " action))
user-handles
nil ; predicate
'confirm)))))
(defun mastodon-tl--interactive-blocks-or-mutes-list-get (action)
"Fetch the list of accounts for ACTION from the server.
Action must be either \"unblock\" or \"unmute\"."
(let* ((endpoint (cond ((equal action "unblock")
"blocks")
((equal action "unmute")
"mutes")))
(url (mastodon-http--api endpoint))
(json (mastodon-http--get-json url))
(accts (mastodon-tl--map-alist 'acct json)))
(when accts
(completing-read (format "Handle of user to %s: " action)
accts
nil ; predicate
t))))
(defun mastodon-tl--do-user-action-and-response
(user-handle action &optional negp notify langs)
"Do ACTION on user USER-HANDLE.
NEGP is whether the action involves un-doing something.
If NOTIFY is \"true\", enable notifications when that user posts.
If NOTIFY is \"false\", disable notifications when that user posts.
NOTIFY is only non-nil when called by `mastodon-tl--follow-user'.
LANGS is an array parameters alist of languages to filer user's posts by."
(let* ((account (if negp
;; if unmuting/unblocking, we got handle from mute/block list
(mastodon-profile--search-account-by-handle
user-handle)
;; if profile view, use 'profile-json as status:
(if (mastodon-tl--profile-buffer-p)
(mastodon-profile--lookup-account-in-status
user-handle (mastodon-profile--profile-json))
;; if muting/blocking, we select from handles in current status
(mastodon-profile--lookup-account-in-status
user-handle (mastodon-profile--toot-json)))))
(user-id (mastodon-profile--account-field account 'id))
(name (if (not (string-empty-p
(mastodon-profile--account-field account 'display_name)))
(mastodon-profile--account-field account 'display_name)
(mastodon-profile--account-field account 'username)))
(args (cond (notify
`(("notify" . ,notify)))
(langs langs)
(t nil)))
(url (mastodon-http--api (format "accounts/%s/%s" user-id action))))
(if account
(if (equal action "follow") ; y-or-n for all but follow
(mastodon-tl--do-user-action-function url name user-handle action notify args)
(when (y-or-n-p (format "%s user %s? " action name))
(mastodon-tl--do-user-action-function url name user-handle action args)))
(message "Cannot find a user with handle %S" user-handle))))
(defun mastodon-tl--do-user-action-function
(url name user-handle action &optional notify args)
"Post ACTION on user NAME/USER-HANDLE to URL.
NOTIFY is either \"true\" or \"false\", and used when we have been called
by `mastodon-tl--follow-user' to enable or disable notifications.
ARGS is an alist of any parameters to send with the request."
(let ((response (mastodon-http--post url args)))
(mastodon-http--triage
response
(lambda ()
(cond ((string-equal notify "true")
(message "Receiving notifications for user %s (@%s)!"
name user-handle))
((string-equal notify "false")
(message "Not receiving notifications for user %s (@%s)!"
name user-handle))
((or (string-equal action "mute")
(string-equal action "unmute"))
(message "User %s (@%s) %sd!" name user-handle action))
((assoc "languages[]" args #'equal)
(message "User %s filtered by language(s): %s" name
(mapconcat #'cdr args " ")))
((eq notify nil)
(message "User %s (@%s) %sed!" name user-handle action)))))))
;; FOLLOW TAGS
(defun mastodon-tl--follow-tag (&optional tag)
"Prompt for a tag and follow it.
If TAG provided, follow it."
(interactive)
(let* ((tag (or tag (read-string "Tag to follow: ")))
(url (mastodon-http--api (format "tags/%s/follow" tag)))
(response (mastodon-http--post url)))
(mastodon-http--triage response
(lambda ()
(message "tag #%s followed!" tag)))))
(defun mastodon-tl--followed-tags ()
"Return JSON of tags followed."
(let ((url (mastodon-http--api (format "followed_tags"))))
(mastodon-http--get-json url)))
(defun mastodon-tl--unfollow-tag (&optional tag)
"Prompt for a followed tag, and unfollow it.
If TAG is provided, unfollow it."
(interactive)
(let* ((followed-tags-json (unless tag (mastodon-tl--followed-tags)))
(tags (unless tag
(mastodon-tl--map-alist 'name followed-tags-json)))
(tag (or tag (completing-read "Unfollow tag: "
tags)))
(url (mastodon-http--api (format "tags/%s/unfollow" tag)))
(response (mastodon-http--post url)))
(mastodon-http--triage response
(lambda ()
(message "tag #%s unfollowed!" tag)))))
(defun mastodon-tl--list-followed-tags (&optional prefix)
"List followed tags. View timeline of tag user choses.
PREFIX is sent to `mastodon-tl--get-tag-timeline', which see."
(interactive "p")
(let* ((followed-tags-json (mastodon-tl--followed-tags))
(tags (mastodon-tl--map-alist 'name followed-tags-json))
(tag (completing-read "Tag: " tags nil)))
(if (null tag)
(message "You have to follow some tags first.")
(mastodon-tl--get-tag-timeline prefix tag))))
(defun mastodon-tl--followed-tags-timeline (&optional prefix)
"Open a timeline of all your followed tags.
PREFIX is sent to `mastodon-tl--show-tag-timeline', which see."
(interactive "p")
(let* ((followed-tags-json (mastodon-tl--followed-tags))
(tags (mastodon-tl--map-alist 'name followed-tags-json)))
(mastodon-tl--show-tag-timeline prefix tags)))
(defun mastodon-tl--some-followed-tags-timeline (&optional prefix)
"Prompt for some tags, and open a timeline for them.
The suggestions are from followed tags, but any other tags are also allowed.
PREFIX us sent to `mastodon-tl--show-tag-timeline', which see."
(interactive "p")
(let* ((followed-tags-json (mastodon-tl--followed-tags))
(tags (mastodon-tl--map-alist 'name followed-tags-json))
(selection (completing-read-multiple
"Tags' timelines to view [TAB to view, comma to separate]: "
tags)))
(mastodon-tl--show-tag-timeline prefix selection)))
;;; REPORT TO MODERATORS
(defun mastodon-tl--instance-rules ()
"Return the rules of the user's instance."
(let ((url (mastodon-http--api "instance/rules")))
(mastodon-http--get-json url nil :silent)))
(defun mastodon-tl--report-params (account toot)
"Query user and return report params alist.
ACCOUNT and TOOT are the data to use."
(let* ((account-id (mastodon-profile--account-field account 'id))
(comment (read-string "Add comment [optional]: "))
(toot-id (when (y-or-n-p "Also report status at point? ")
(mastodon-tl--toot-id toot))) ; base toot if poss
(forward-p (when (y-or-n-p "Forward to remote admin? ") "true"))
(rules (when (y-or-n-p "Cite a rule broken? ")
(mastodon-tl--read-rules-ids)))
(cat (unless rules (if (y-or-n-p "Spam? ") "spam" "other"))))
(mastodon-tl--report-build-params account-id comment toot-id
forward-p cat rules)))
(defun mastodon-tl--report-build-params
(account-id comment toot-id forward-p cat &optional rules)
"Build the parameters alist based on user responses.
ACCOUNT-ID, COMMENT, TOOT-ID, FORWARD-P, CAT, and RULES are all from
`mastodon-tl--report-params', which see."
(let ((params `(("account_id" . ,account-id)
,(when comment
`("comment" . ,comment))
,(when toot-id
`("status_ids[]" . ,toot-id))
,(when forward-p
`("forward" . ,forward-p))
,(when cat
`("category" . ,cat)))))
(when rules
(let ((alist
(mastodon-http--build-array-params-alist "rule_ids[]" rules)))
(mapc (lambda (x)
(push x params))
alist)))
;; FIXME: the above approach adds nils to your params.
(setq params (delete nil params))
params))
(defun mastodon-tl--report-to-mods ()
"Report the author of the toot at point to your instance moderators.
Optionally report the toot at point, add a comment, cite rules
that have been broken, forward the report to the remove admin,
report the account for spam."
(interactive)
(mastodon-tl--do-if-toot
(when (y-or-n-p "Report author of toot at point?")
(let* ((url (mastodon-http--api "reports"))
(toot (mastodon-tl--toot-or-base
(mastodon-tl--property 'toot-json :no-move)))
(account (alist-get 'account toot))
(handle (alist-get 'acct account))
(params (mastodon-tl--report-params account toot))
(response (mastodon-http--post url params)))
;; (setq masto-report-response response)
(mastodon-http--triage response
(lambda ()
(message "User %s reported!" handle)))))))
(defvar crm-separator)
(defun mastodon-tl--map-rules-alist (rules)
"Return an alist of the text and id fields of RULES."
(mapcar (lambda (x)
(let-alist x
(cons .text .id)))
rules))
(defun mastodon-tl--read-rules-ids ()
"Prompt for a list of instance rules and return a list of selected ids."
(let* ((rules (mastodon-tl--instance-rules))
(alist (mastodon-tl--map-rules-alist rules))
(crm-separator (replace-regexp-in-string "," "|" crm-separator))
(choices (completing-read-multiple
"rules [TAB for options, | to separate]: "
alist nil :match)))
(mapcar (lambda (x)
(alist-get x alist nil nil 'equal))
choices)))
;;; UPDATING, etc.
(defun mastodon-tl--more-json (endpoint id)
"Return JSON for timeline ENDPOINT before ID."
(let* ((args `(("max_id" . ,(mastodon-tl--as-string id))))
(url (mastodon-http--api endpoint)))
(mastodon-http--get-json url args)))
(defun mastodon-tl--more-json-async (endpoint id &optional params callback &rest cbargs)
"Return JSON for timeline ENDPOINT before ID.
Then run CALLBACK with arguments CBARGS.
PARAMS is used to send any parameters needed to correctly update
the current view."
(let* ((args `(("max_id" . ,(mastodon-tl--as-string id))))
(args (if params (push (car args) params) args))
(url (mastodon-http--api endpoint)))
(apply #'mastodon-http--get-json-async url args callback cbargs)))
(defun mastodon-tl--updated-json (endpoint id &optional params)
"Return JSON for timeline ENDPOINT since ID.
PARAMS is used to send any parameters needed to correctly update
the current view."
(let* ((args `(("since_id" . ,(mastodon-tl--as-string id))))
(args (if params (push (car args) params) args))
(url (mastodon-http--api endpoint)))
(mastodon-http--get-json url args)))
;; TODO: add this to new posts in some cases, e.g. in thread view.
(defun mastodon-tl--reload-timeline-or-profile (&optional pos)
"Reload the current timeline or profile page.
For use after e.g. deleting a toot.
POS is a number, where point will be placed."
(let ((type (mastodon-tl--get-buffer-type)))
(cond ((eq type 'home)
(mastodon-tl--get-home-timeline))
((eq type 'federated)
(mastodon-tl--get-federated-timeline))
((eq type 'local)
(mastodon-tl--get-local-timeline))
((eq type 'mentions)
(mastodon-notifications--get-mentions))
((eq type 'notifications)
(mastodon-notifications-get nil nil :force))
((eq type 'profile-statuses-no-boosts)
(mastodon-profile--open-statuses-no-reblogs))
((eq type 'profile-statuses)
(mastodon-profile--my-profile))
((eq type 'thread)
(save-match-data
(let ((endpoint (mastodon-tl--get-endpoint)))
(string-match
"statuses/\\(?2:[[:digit:]]+\\)/context"
endpoint)
(mastodon-tl--thread
(match-string 2 endpoint))))))
;; TODO: sends point to POS, which was where point was in buffer before
;; reload. This is very rough; we may have removed an item (deleted a
;; toot, cleared a notif), so the buffer will be smaller, point will end
;; up past where we were, etc.
(when pos
(goto-char pos)
(mastodon-tl--goto-prev-item))))
(defun mastodon-tl--build-link-header-url (str)
"Return a URL from STR, an http Link header."
(let* ((split (split-string str "; "))
(url-base (string-trim (car split) "<" ">"))
(param (cadr split)))
(concat url-base "&" param)))
(defun mastodon-tl--use-link-header-p ()
"Return t if we are in a view needing Link header pagination.
Currently this includes favourites, bookmarks, and profile pages
when showing followers or accounts followed."
(or (mastodon-tl--buffer-type-eq 'favourites)
(mastodon-tl--buffer-type-eq 'bookmarks)
(mastodon-tl--buffer-type-eq 'profile-followers)
(mastodon-tl--buffer-type-eq 'profile-following)))
(defun mastodon-tl--get-link-header-from-response (headers)
"Get http Link header from list of http HEADERS."
;; pleroma uses "link", so case-insensitive match required:
(when-let ((link-headers (alist-get "Link" headers nil nil 'cl-equalp)))
(split-string link-headers ", ")))
(defun mastodon-tl--more ()
"Append older toots to timeline, asynchronously."
(message "Loading older toots...")
(if (mastodon-tl--use-link-header-p)
;; link-header: can't build a URL with --more-json-async, endpoint/id:
;; ensure we have a "next" type here, otherwise the CAR will be the
;; "prev" type!
(let ((link-header (mastodon-tl--link-header)))
(if (> 2 (length link-header))
(error "No next page")
(let* ((next (car link-header))
;;(prev (cadr (mastodon-tl--link-header)))
(url (mastodon-tl--build-link-header-url next)))
(mastodon-http--get-response-async url nil 'mastodon-tl--more* (current-buffer)
(point) :headers))))
(mastodon-tl--more-json-async
(mastodon-tl--get-endpoint)
(mastodon-tl--oldest-id)
(mastodon-tl--update-params)
'mastodon-tl--more* (current-buffer) (point))))
(defun mastodon-tl--more* (response buffer point-before &optional headers)
"Append older toots to timeline, asynchronously.
Runs the timeline's update function on RESPONSE, in BUFFER.
When done, places point at POINT-BEFORE.
HEADERS is the http headers returned in the response, if any."
(with-current-buffer buffer
(when response
(let* ((inhibit-read-only t)
(json (if headers (car response) response))
(headers (if headers (cdr response) nil))
(link-header (mastodon-tl--get-link-header-from-response headers)))
(goto-char (point-max))
(if (eq (mastodon-tl--get-buffer-type) 'thread)
;; if thread view, call --thread with parent ID
(progn (goto-char (point-min))
(mastodon-tl--goto-next-toot)
(funcall (mastodon-tl--get-update-function)))
(funcall (mastodon-tl--get-update-function) json))
(goto-char point-before)
;; update buffer spec to new link-header:
;; (other values should just remain as they were)
(when headers
(mastodon-tl--set-buffer-spec (mastodon-tl--buffer-name)
(mastodon-tl--get-endpoint)
(mastodon-tl--get-update-function)
link-header))
(message "Loading older toots... done.")))))
(defun mastodon-tl--find-property-range (property start-point &optional search-backwards)
"Return `nil` if no such range is found.
If PROPERTY is set at START-POINT returns a range around
START-POINT otherwise before/after START-POINT.
SEARCH-BACKWARDS determines whether we pick point
before (non-nil) or after (nil)"
(if (get-text-property start-point property)
;; We are within a range, so look backwards for the start:
(cons (previous-single-property-change
(if (equal start-point (point-max)) start-point (1+ start-point))
property nil (point-min))
(next-single-property-change start-point property nil (point-max)))
(if search-backwards
(let* ((end (or (previous-single-property-change
(if (equal start-point (point-max))
start-point (1+ start-point))
property)
;; we may either be just before the range or there
;; is nothing at all
(and (not (equal start-point (point-min)))
(get-text-property (1- start-point) property)
start-point)))
(start (and
end
(previous-single-property-change end property nil (point-min)))))
(when end
(cons start end)))
(let* ((start (next-single-property-change start-point property))
(end (and start
(next-single-property-change start property nil (point-max)))))
(when start
(cons start end))))))
(defun mastodon-tl--find-next-or-previous-property-range
(property start-point search-backwards)
"Find (start . end) property range after/before START-POINT.
Does so while PROPERTY is set to a consistent value (different
from the value at START-POINT if that is set).
Return nil if no such range exists.
If SEARCH-BACKWARDS is non-nil it find a region before
START-POINT otherwise after START-POINT."
(if (get-text-property start-point property)
;; We are within a range, we need to start the search from
;; before/after this range:
(let ((current-range (mastodon-tl--find-property-range property start-point)))
(if search-backwards
(unless (equal (car current-range) (point-min))
(mastodon-tl--find-property-range
property (1- (car current-range)) search-backwards))
(unless (equal (cdr current-range) (point-max))
(mastodon-tl--find-property-range
property (1+ (cdr current-range)) search-backwards))))
;; If we are not within a range, we can just defer to
;; mastodon-tl--find-property-range directly.
(mastodon-tl--find-property-range property start-point search-backwards)))
(defun mastodon-tl--consider-timestamp-for-updates (timestamp)
"Take note that TIMESTAMP is used in buffer and ajust timers as needed.
This calculates the next time the text for TIMESTAMP will change
and may adjust existing or future timer runs should that time
before current plans to run the update function.
The adjustment is only made if it is significantly (a few
seconds) before the currently scheduled time. This helps reduce
the number of occasions where we schedule an update only to
schedule the next one on completion to be within a few seconds.
If relative timestamps are disabled (i.e. if
`mastodon-tl--enable-relative-timestamps' is nil), this is a
no-op."
(when mastodon-tl--enable-relative-timestamps
(let ((this-update (cdr (mastodon-tl--relative-time-details timestamp))))
(when (time-less-p this-update
(time-subtract mastodon-tl--timestamp-next-update
(seconds-to-time 10)))
(setq mastodon-tl--timestamp-next-update this-update)
(when mastodon-tl--timestamp-update-timer
;; We need to re-schedule for an earlier time
(cancel-timer mastodon-tl--timestamp-update-timer)
(setq mastodon-tl--timestamp-update-timer
(run-at-time (time-to-seconds (time-subtract this-update (current-time)))
nil ;; don't repeat
#'mastodon-tl--update-timestamps-callback
(current-buffer) nil)))))))
(defun mastodon-tl--update-timestamps-callback (buffer previous-marker)
"Update the next few timestamp displays in BUFFER.
Start searching for more timestamps from PREVIOUS-MARKER or
from the start if it is nil."
;; only do things if the buffer hasn't been killed in the meantime
(when (and mastodon-tl--enable-relative-timestamps ;; should be true but just in case...
(buffer-live-p buffer))
(save-excursion
(with-current-buffer buffer
(let ((previous-timestamp (if previous-marker
(marker-position previous-marker)
(point-min)))
(iteration 0)
next-timestamp-range)
(if previous-marker
;; This is a follow-up call to process the next batch of
;; timestamps.
;; Release the marker to not slow things down.
(set-marker previous-marker nil)
;; Otherwise this is a rew run, so let's initialize the next-run time.
(setq mastodon-tl--timestamp-next-update (time-add (current-time)
(seconds-to-time 300))
mastodon-tl--timestamp-update-timer nil))
(while (and (< iteration 5)
(setq next-timestamp-range
(mastodon-tl--find-property-range 'timestamp
previous-timestamp)))
(let* ((start (car next-timestamp-range))
(end (cdr next-timestamp-range))
(timestamp (get-text-property start 'timestamp))
(current-display (get-text-property start 'display))
(new-display (mastodon-tl--relative-time-description timestamp)))
(unless (string= current-display new-display)
(let ((inhibit-read-only t))
(add-text-properties
start end (list 'display
(mastodon-tl--relative-time-description timestamp)))))
(mastodon-tl--consider-timestamp-for-updates timestamp)
(setq iteration (1+ iteration)
previous-timestamp (1+ (cdr next-timestamp-range)))))
(if next-timestamp-range
;; schedule the next batch from the previous location to
;; start very soon in the future:
(run-at-time 0.1 nil #'mastodon-tl--update-timestamps-callback buffer
(copy-marker previous-timestamp))
;; otherwise we are done for now; schedule a new run for when needed
(setq mastodon-tl--timestamp-update-timer
(run-at-time (time-to-seconds
(time-subtract mastodon-tl--timestamp-next-update
(current-time)))
nil ;; don't repeat
#'mastodon-tl--update-timestamps-callback
buffer nil))))))))
(defun mastodon-tl--set-after-update-marker ()
"Set `mastodon-tl--after-update-marker' to the after-update location.
This location is defined by a non-nil value of
`mastodon-tl-position-after-update'."
(if mastodon-tl-position-after-update
(let ((marker (make-marker)))
(set-marker marker
(cond
((eq 'keep-point mastodon-tl-position-after-update)
(point))
((eq 'last-old-toot mastodon-tl-position-after-update)
(next-single-property-change
(or mastodon-tl--update-point (point-min))
'byline))
(t
(error "Unknown mastodon-tl-position-after-update value %S"
mastodon-tl-position-after-update))))
;; Make the marker advance if text gets inserted there.
(set-marker-insertion-type marker t)
(setq mastodon-tl--after-update-marker marker))
(setq mastodon-tl--after-update-marker nil)))
(defun mastodon-tl--update ()
"Update timeline with new toots."
(interactive)
(let* ((endpoint (mastodon-tl--get-endpoint))
(update-function (mastodon-tl--get-update-function))
(thread-id (mastodon-tl--property 'toot-id)))
;; update a thread, without calling `mastodon-tl--updated-json':
(if (mastodon-tl--buffer-type-eq 'thread)
(funcall update-function thread-id)
;; update other timelines:
(let* ((id (mastodon-tl--newest-id))
(params (mastodon-tl--update-params))
(json (mastodon-tl--updated-json endpoint id params)))
(if json
(let ((inhibit-read-only t))
(mastodon-tl--set-after-update-marker)
(goto-char (or mastodon-tl--update-point (point-min)))
(funcall update-function json)
(when mastodon-tl--after-update-marker
(goto-char mastodon-tl--after-update-marker)))
(message "nothing to update"))))))
;;; LOADING TIMELINES
(defun mastodon-tl--init (buffer-name endpoint update-function
&optional headers params hide-replies)
"Initialize BUFFER-NAME with timeline targeted by ENDPOINT asynchronously.
UPDATE-FUNCTION is used to recieve more toots.
HEADERS means to also collect the response headers. Used for paginating
favourites and bookmarks.
PARAMS is any parameters to send with the request.
HIDE-REPLIES is a flag indicating if replies are hidden in the current buffer."
(let ((url (mastodon-http--api endpoint))
(buffer (concat "*mastodon-" buffer-name "*")))
(if headers
(mastodon-http--get-response-async url params
'mastodon-tl--init* buffer endpoint update-function
headers params hide-replies)
(mastodon-http--get-json-async url params
'mastodon-tl--init* buffer endpoint update-function nil
params hide-replies))))
(defun mastodon-tl--init* (response buffer endpoint update-function
&optional headers update-params hide-replies)
"Initialize BUFFER with timeline targeted by ENDPOINT.
UPDATE-FUNCTION is used to recieve more toots.
RESPONSE is the data returned from the server by
`mastodon-http--process-json', with arg HEADERS a cons cell of
JSON and http headers, without it just the JSON."
(let ((json (if headers (car response) response)))
(if (not json) ; praying this is right here, else try "\n[]"
(message "Looks like nothing returned from endpoint: %s" endpoint)
(let* ((headers (if headers (cdr response) nil))
(link-header (mastodon-tl--get-link-header-from-response headers)))
(with-mastodon-buffer buffer #'mastodon-mode nil
(mastodon-tl--set-buffer-spec buffer
endpoint
update-function
link-header
update-params
hide-replies)
(funcall update-function json)
(setq
;; Initialize with a minimal interval; we re-scan at least once
;; every 5 minutes to catch any timestamps we may have missed
mastodon-tl--timestamp-next-update (time-add (current-time)
(seconds-to-time 300)))
(setq mastodon-tl--timestamp-update-timer
(when mastodon-tl--enable-relative-timestamps
(run-at-time (time-to-seconds
(time-subtract mastodon-tl--timestamp-next-update
(current-time)))
nil ;; don't repeat
#'mastodon-tl--update-timestamps-callback
(current-buffer)
nil)))
(unless (mastodon-tl--profile-buffer-p)
(mastodon-tl--goto-first-item)))))))
(defun mastodon-tl--init-sync (buffer-name endpoint update-function &optional note-type)
"Initialize BUFFER-NAME with timeline targeted by ENDPOINT.
UPDATE-FUNCTION is used to receive more toots.
Runs synchronously.
Optional arg NOTE-TYPE means only get that type of note."
(let* ((exclude-types (when note-type
(mastodon-notifications--filter-types-list note-type)))
(args (when note-type (mastodon-http--build-array-params-alist
"exclude_types[]" exclude-types)))
;; NB: we now store 'update-params separately in `mastodon-tl--buffer-spec'
;; and -http.el handles all conversion of params alists into query strings.
(url (mastodon-http--api endpoint))
(buffer (concat "*mastodon-" buffer-name "*"))
(json (mastodon-http--get-json url args)))
(with-mastodon-buffer buffer #'mastodon-mode nil
(setq
;; Initialize with a minimal interval; we re-scan at least once
;; every 5 minutes to catch any timestamps we may have missed
mastodon-tl--timestamp-next-update (time-add (current-time)
(seconds-to-time 300)))
(funcall update-function json)
(mastodon-tl--set-buffer-spec buffer endpoint update-function nil args)
(setq mastodon-tl--timestamp-update-timer
(when mastodon-tl--enable-relative-timestamps
(run-at-time (time-to-seconds
(time-subtract mastodon-tl--timestamp-next-update
(current-time)))
nil ;; don't repeat
#'mastodon-tl--update-timestamps-callback
(current-buffer)
nil)))
(unless (mastodon-tl--profile-buffer-p)
;; FIXME: this breaks test (because test has empty buffer)
(mastodon-tl--goto-first-item)))
buffer))
(provide 'mastodon-tl)
;;; mastodon-tl.el ends here
;;; mastodon-search.el --- Search functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Marty Hiatt
;; Author: Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; A basic search function for mastodon.el
;;; Code:
(require 'json)
(eval-when-compile
(require 'mastodon-tl))
(autoload 'mastodon-auth--access-token "mastodon-auth")
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--get-json "mastodon-http")
(autoload 'mastodon-http--get-search-json "mastodon-http")
(autoload 'mastodon-mode "mastodon")
(autoload 'mastodon-tl--as-string "mastodon-tl")
(autoload 'mastodon-tl--as-string "mastodon-tl")
(autoload 'mastodon-tl--render-text "mastodon-tl")
(autoload 'mastodon-tl--set-buffer-spec "mastodon-tl")
(autoload 'mastodon-tl--set-face "mastodon-tl")
(autoload 'mastodon-tl--timeline "mastodon-tl")
(autoload 'mastodon-tl--toot "mastodon-tl")
(defvar mastodon-toot--completion-style-for-mentions)
(defvar mastodon-instance-url)
(defvar mastodon-tl--link-keymap)
(defvar mastodon-tl--horiz-bar)
;; functions for completion of mentions in mastodon-toot
(defun mastodon-search--get-user-info-@ (account)
"Get user handle, display name and account URL from ACCOUNT."
(list (concat "@" (cdr (assoc 'acct account)))
(cdr (assoc 'url account))
(cdr (assoc 'display_name account))))
(defun mastodon-search--search-accounts-query (query)
"Prompt for a search QUERY and return accounts synchronously.
Returns a nested list containing user handle, display name, and URL."
(let* ((url (mastodon-http--api "accounts/search"))
(response (if (equal mastodon-toot--completion-style-for-mentions "following")
(mastodon-http--get-json url `(("q" . ,query) ("following" . "true")) :silent)
(mastodon-http--get-json url `(("q" . ,query)) :silent))))
(mapcar #'mastodon-search--get-user-info-@ response)))
;; functions for tags completion:
(defun mastodon-search--search-tags-query (query)
"Return an alist containing tag strings plus their URLs.
QUERY is the string to search."
(let* ((url (format "%s/api/v2/search" mastodon-instance-url))
(params `(("q" . ,query)
("type" . "hashtags")))
(response (mastodon-http--get-json url params :silent))
(tags (alist-get 'hashtags response)))
(mapcar #'mastodon-search--get-hashtag-info tags)))
;; trending tags
(defun mastodon-search--trending-tags ()
"Display a list of tags trending on your instance."
(interactive)
(mastodon-search--view-trending "tags"
#'mastodon-search--print-tags-list))
(defun mastodon-search--trending-statuses ()
"Display a list of statuses trending on your instance."
(interactive)
(mastodon-search--view-trending "statuses"
#'mastodon-tl--timeline))
(defun mastodon-search--get-full-statuses-data (response)
"For statuses list in RESPONSE, fetch and return full status JSON."
(let ((status-ids-list
(mapcar #'mastodon-search--get-id-from-status response)))
(mapcar #'mastodon-search--fetch-full-status-from-id
status-ids-list)))
(defun mastodon-search--view-trending (type print-fun)
"Display a list of tags trending on your instance.
TYPE is a string, either tags, statuses, or links.
PRINT-FUN is the function used to print the data from the response."
(let* ((url (mastodon-http--api
(format "trends/%s" type)))
;; max for statuses = 40, for others = 20
(params (if (equal type "statuses")
`(("limit" . "40"))
`(("limit" . "20")) ))
(response (mastodon-http--get-json url params))
(data (cond ((equal type "tags")
(mapcar #'mastodon-search--get-hashtag-info
response))
((equal type "statuses")
(mastodon-search--get-full-statuses-data response))
((equal type "links")
(message "todo"))))
(buffer (get-buffer-create
(format "*mastodon-trending-%s*" type))))
(with-mastodon-buffer buffer #'mastodon-mode nil
(mastodon-tl--set-buffer-spec (buffer-name buffer)
(format "api/v1/trends/%s" type)
nil)
(insert (mastodon-tl--set-face
(concat "\n " mastodon-tl--horiz-bar "\n"
(upcase (format " TRENDING %s\n" type))
" " mastodon-tl--horiz-bar "\n\n")
'success))
(funcall print-fun data)
(unless (equal type "statuses")
(goto-char (point-min))))))
;; functions for mastodon search
(defun mastodon-search--search-query (query)
"Prompt for a search QUERY and return accounts, statuses, and hashtags."
(interactive "sSearch mastodon for: ")
(let* ((url (format "%s/api/v2/search" mastodon-instance-url))
(buffer (format "*mastodon-search-%s*" query))
(response (mastodon-http--get-json url `(("q" . ,query))))
(accts (alist-get 'accounts response))
(tags (alist-get 'hashtags response))
(statuses (alist-get 'statuses response))
;; this is now done in search--insert-users-propertized
;; (user-ids (mapcar #'mastodon-search--get-user-info
;; accts)) ; returns a list of three-item lists
(tags-list (mapcar #'mastodon-search--get-hashtag-info
tags))
(toots-list-json
(mastodon-search--get-full-statuses-data statuses)))
(with-mastodon-buffer buffer #'mastodon-mode nil
(mastodon-tl--set-buffer-spec buffer
"api/v2/search"
nil)
;; user results:
(insert (mastodon-tl--set-face
(concat "\n " mastodon-tl--horiz-bar "\n"
" USERS\n"
" " mastodon-tl--horiz-bar "\n\n")
'success))
(mastodon-search--insert-users-propertized accts :note)
;; hashtag results:
(insert (mastodon-tl--set-face
(concat "\n " mastodon-tl--horiz-bar "\n"
" HASHTAGS\n"
" " mastodon-tl--horiz-bar "\n\n")
'success))
(mastodon-search--print-tags-list tags-list)
;; status results:
(insert (mastodon-tl--set-face
(concat "\n " mastodon-tl--horiz-bar "\n"
" STATUSES\n"
" " mastodon-tl--horiz-bar "\n")
'success))
(mapc #'mastodon-tl--toot toots-list-json)
(goto-char (point-min)))))
(defun mastodon-search--insert-users-propertized (json &optional note)
"Insert users list into the buffer.
JSON is the data from the server. If NOTE is non-nil, include
user's profile note. This is also called by
`mastodon-tl--get-follow-suggestions' and
`mastodon-profile--insert-follow-requests'."
(mapc (lambda (acct)
(insert (concat (mastodon-search--propertize-user acct note)
mastodon-tl--horiz-bar
"\n\n")))
json))
(defun mastodon-search--propertize-user (acct &optional note)
"Propertize display string for ACCT, optionally including profile NOTE."
(let ((user (mastodon-search--get-user-info acct)))
(propertize
(concat (propertize (car user)
'face 'mastodon-display-name-face
'byline t
'toot-id "0")
" : \n : "
(propertize (concat "@" (cadr user))
'face 'mastodon-handle-face
'mouse-face 'highlight
'mastodon-tab-stop 'user-handle
'keymap mastodon-tl--link-keymap
'mastodon-handle (concat "@" (cadr user))
'help-echo (concat "Browse user profile of @" (cadr user)))
" : \n"
(if note
(mastodon-tl--render-text (cadddr user) acct)
"")
"\n")
'toot-json acct))) ; so named for compat w other processing functions
(defun mastodon-search--print-tags-list (tags)
"Insert a propertized list of TAGS."
(mapc (lambda (el)
(insert
" : "
(propertize (concat "#"
(car el))
'face '(:box t)
'mouse-face 'highlight
'mastodon-tag (car el)
'mastodon-tab-stop 'hashtag
'help-echo (concat "Browse tag #" (car el))
'keymap mastodon-tl--link-keymap)
" : \n\n"))
tags))
(defun mastodon-search--get-user-info (account)
"Get user handle, display name, account URL and profile note from ACCOUNT."
(list (if (not (string-empty-p (alist-get 'display_name account)))
(alist-get 'display_name account)
(alist-get 'username account))
(alist-get 'acct account)
(alist-get 'url account)
(alist-get 'note account)))
(defun mastodon-search--get-hashtag-info (tag)
"Get hashtag name and URL from TAG."
(list (alist-get 'name tag)
(alist-get 'url tag)))
(defun mastodon-search--get-status-info (status)
"Get ID, timestamp, content, and spoiler from STATUS."
(list (alist-get 'id status)
(alist-get 'created_at status)
(alist-get 'spoiler_text status)
(alist-get 'content status)))
(defun mastodon-search--get-id-from-status (status)
"Fetch the id from a STATUS returned by a search call to the server.
We use this to fetch the complete status from the server."
(alist-get 'id status))
(defun mastodon-search--fetch-full-status-from-id (id)
"Fetch the full status with id ID from the server.
This allows us to access the full account etc. details and to
render them properly."
(let* ((url (concat mastodon-instance-url "/api/v1/statuses/" (mastodon-tl--as-string id)))
(json (mastodon-http--get-json url)))
json))
(provide 'mastodon-search)
;;; mastodon-search.el ends here
;;; mastodon-profile.el --- Functions for inspecting Mastodon profiles -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-profile.el generates a stream of users toots.
;; To add
;; - Option to follow
;; - wheather they follow you or not
;; - Show only Media
;;; Code:
(require 'seq)
(require 'cl-lib)
(require 'persist)
(require 'parse-time)
(eval-when-compile
(require 'mastodon-tl))
(autoload 'mastodon-auth--get-account-id "mastodon-auth")
(autoload 'mastodon-auth--get-account-name "mastodon-auth.el")
(autoload 'mastodon-http--api "mastodon-http.el")
(autoload 'mastodon-http--get-json "mastodon-http.el")
(autoload 'mastodon-http--get-json-async "mastodon-http.el")
(autoload 'mastodon-http--get-response "mastodon-http")
(autoload 'mastodon-http--patch "mastodon-http")
(autoload 'mastodon-http--patch-json "mastodon-http")
(autoload 'mastodon-http--post "mastodon-http.el")
(autoload 'mastodon-http--triage "mastodon-http.el")
(autoload 'mastodon-media--get-media-link-rendering "mastodon-media.el")
(autoload 'mastodon-media--inline-images "mastodon-media.el")
(autoload 'mastodon-mode "mastodon.el")
(autoload 'mastodon-notifications--follow-request-accept "mastodon-notifications")
(autoload 'mastodon-notifications--follow-request-reject "mastodon-notifications")
(autoload 'mastodon-search--insert-users-propertized "mastodon-search")
(autoload 'mastodon-tl--as-string "mastodon-tl.el")
(autoload 'mastodon-tl--buffer-type-eq "mastodon tl")
(autoload 'mastodon-tl--byline-author "mastodon-tl.el")
(autoload 'mastodon-tl--find-property-range "mastodon-tl.el")
(autoload 'mastodon-tl--get-link-header-from-response "mastodon-tl")
(autoload 'mastodon-tl--init "mastodon-tl.el")
(autoload 'mastodon-tl--interactive-user-handles-get "mastodon-tl")
(autoload 'mastodon-tl--map-alist "mastodon-tl")
(autoload 'mastodon-tl--map-alist-vals-to-alist "mastodon-tl")
(autoload 'mastodon-tl--profile-buffer-p "mastodon tl")
(autoload 'mastodon-tl--property "mastodon-tl.el")
(autoload 'mastodon-tl--render-text "mastodon-tl.el")
(autoload 'mastodon-tl--set-buffer-spec "mastodon-tl")
(autoload 'mastodon-tl--set-face "mastodon-tl.el")
(autoload 'mastodon-tl--symbol "mastodon-tl")
(autoload 'mastodon-tl--timeline "mastodon-tl.el")
(autoload 'mastodon-tl--toot "mastodon-tl")
(autoload 'mastodon-tl--toot-id "mastodon-tl")
(autoload 'mastodon-toot--count-toot-chars "mastodon-toot")
(autoload 'mastodon-toot--get-max-toot-chars "mastodon-toot")
(autoload 'mastodon-views--add-account-to-list "mastodon-views")
(defvar mastodon-tl--horiz-bar)
(defvar mastodon-tl--update-point)
(defvar mastodon-toot--max-toot-chars)
(defvar mastodon-toot--visibility)
(defvar mastodon-toot--content-nsfw)
(defvar mastodon-tl--timeline-posts-count)
(defvar-local mastodon-profile--account nil
"The data for the account being described in the current profile buffer.")
(defvar mastodon-profile-mode-map
(let ((map (make-sparse-keymap)))
;; conflicts with `s' keybinding to translate toot at point
;; seeing as we now have the C-c C-c cycle functionality,
;; maybe we can retire both of these awful bindings
;; (define-key map (kbd "s") #'mastodon-profile--open-followers)
;; (define-key map (kbd "g") #'mastodon-profile--open-following)
(define-key map (kbd "C-c C-c") #'mastodon-profile--account-view-cycle)
map)
"Keymap for `mastodon-profile-mode'.")
(define-minor-mode mastodon-profile-mode
"Toggle mastodon profile minor mode.
This minor mode is used for mastodon profile pages and adds a couple of
extra keybindings."
:init-value nil
;; modeline indicator:
:lighter " Profile"
:keymap mastodon-profile-mode-map
:group 'mastodon
:global nil)
(defvar mastodon-profile-update-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c C-c") #'mastodon-profile--user-profile-send-updated)
(define-key map (kbd "C-c C-k") #'mastodon-profile--update-profile-note-cancel)
map)
"Keymap for `mastodon-profile-update-mode'.")
(persist-defvar mastodon-profile-account-settings nil
"An alist of account settings saved from the server.
Other clients can change these settings on the server at any
time, so this list is not the canonical source for settings. It
is updated on entering mastodon mode and on toggle any setting it
contains")
(define-minor-mode mastodon-profile-update-mode
"Minor mode to update Mastodon user profile."
:group 'mastodon-profile
:keymap mastodon-profile-update-mode-map
:global nil)
(defun mastodon-profile--toot-json ()
"Get the next toot-json."
(interactive)
(mastodon-tl--property 'toot-json))
(defun mastodon-profile--make-author-buffer (account &optional no-reblogs)
"Take an ACCOUNT json and insert a user account into a new buffer.
NO-REBLOGS means do not display boosts in statuses."
(mastodon-profile--make-profile-buffer-for
account "statuses" #'mastodon-tl--timeline no-reblogs))
;; TODO: we shd just load all views' data then switch coz this is slow af:
(defun mastodon-profile--account-view-cycle ()
"Cycle through profile view: toots, toot sans boosts, followers, and following."
(interactive)
(cond ((mastodon-tl--buffer-type-eq 'profile-statuses)
(mastodon-profile--open-statuses-no-reblogs))
((mastodon-tl--buffer-type-eq 'profile-statuses-no-boosts)
(mastodon-profile--open-followers))
((mastodon-tl--buffer-type-eq 'profile-followers)
(mastodon-profile--open-following))
((mastodon-tl--buffer-type-eq 'profile-following)
(mastodon-profile--make-author-buffer mastodon-profile--account))))
(defun mastodon-profile--open-statuses-no-reblogs ()
"Open a profile buffer showing statuses without reblogs."
(interactive)
(if mastodon-profile--account
(mastodon-profile--make-author-buffer mastodon-profile--account :no-reblogs)
(error "Not in a mastodon profile")))
(defun mastodon-profile--open-following ()
"Open a profile buffer showing the accounts that current profile follows."
(interactive)
(if mastodon-profile--account
(mastodon-profile--make-profile-buffer-for
mastodon-profile--account
"following"
#'mastodon-profile--add-author-bylines
nil
:headers)
(error "Not in a mastodon profile")))
(defun mastodon-profile--open-followers ()
"Open a profile buffer showing the accounts following the current profile."
(interactive)
(if mastodon-profile--account
(mastodon-profile--make-profile-buffer-for
mastodon-profile--account
"followers"
#'mastodon-profile--add-author-bylines
nil
:headers)
(error "Not in a mastodon profile")))
(defun mastodon-profile--view-favourites ()
"Open a new buffer displaying the user's favourites."
(interactive)
(message "Loading your favourited toots...")
(mastodon-tl--init "favourites"
"favourites"
'mastodon-tl--timeline
:headers))
(defun mastodon-profile--view-bookmarks ()
"Open a new buffer displaying the user's bookmarks."
(interactive)
(message "Loading your bookmarked toots...")
(mastodon-tl--init "bookmarks"
"bookmarks"
'mastodon-tl--timeline
:headers))
(defun mastodon-profile--add-account-to-list ()
"Add account of current profile buffer to a list."
(interactive)
(when mastodon-profile--account
(let* ((profile mastodon-profile--account)
(id (alist-get 'id profile))
(handle (alist-get 'acct profile)))
(mastodon-views--add-account-to-list nil id handle))))
;;; ACCOUNT PREFERENCES
(defun mastodon-profile--get-json-value (val)
"Fetch current VAL ue from account."
(let* ((url (mastodon-http--api "accounts/verify_credentials"))
(response (mastodon-http--get-json url)))
(if (eq (alist-get val response) ':json-false)
nil
(alist-get val response))))
(defun mastodon-profile--get-source-values ()
"Return the \"source\" preferences from the server."
(mastodon-profile--get-json-value 'source))
(defun mastodon-profile--get-source-value (pref)
"Return account PREF erence from the \"source\" section on the server."
(let ((source (mastodon-profile--get-source-values)))
(if (eq (alist-get pref source) ':json-false)
nil
(alist-get pref source))))
(defun mastodon-profile--update-user-profile-note ()
"Fetch user's profile note and display for editing."
(interactive)
(let* ((endpoint "accounts/verify_credentials")
(url (mastodon-http--api endpoint))
(json (mastodon-http--get-json url))
(source (alist-get 'source json))
(note (alist-get 'note source))
(buffer (get-buffer-create "*mastodon-update-profile*"))
(inhibit-read-only t)
(msg-str "Edit your profile note. C-c C-c to send, C-c C-k to cancel."))
(switch-to-buffer-other-window buffer)
(text-mode)
(mastodon-tl--set-buffer-spec (buffer-name buffer)
endpoint
nil)
(setq-local header-line-format
(propertize msg-str
'face font-lock-comment-face))
(mastodon-profile-update-mode t)
(insert (propertize (concat (propertize "0"
'note-counter t
'display nil)
"/500 characters")
'read-only t
'face font-lock-comment-face
'note-header t)
"\n")
(make-local-variable 'after-change-functions)
(push #'mastodon-profile--update-note-count after-change-functions)
(let ((start-point (point)))
(insert note)
(goto-char start-point))
(delete-trailing-whitespace) ; remove all ^M's
(message msg-str)))
(defun mastodon-profile--update-note-count (&rest _args)
"Display the character count of the profile note buffer."
(let ((inhibit-read-only t)
(header-region (mastodon-tl--find-property-range 'note-header
(point-min)))
(count-region (mastodon-tl--find-property-range 'note-counter
(point-min))))
(add-text-properties (car count-region) (cdr count-region)
(list 'display
(number-to-string
(mastodon-toot--count-toot-chars
(buffer-substring-no-properties
(cdr header-region) (point-max))))))))
(defun mastodon-profile--update-profile-note-cancel ()
"Cancel updating user profile and kill buffer and window."
(interactive)
(when (y-or-n-p "Cancel updating your profile note?")
(kill-buffer-and-window)))
(defun mastodon-profile--note-remove-header ()
"Get the body of a toot from the current compose buffer."
(let ((header-region (mastodon-tl--find-property-range 'note-header
(point-min))))
(buffer-substring (cdr header-region) (point-max))))
(defun mastodon-profile--user-profile-send-updated ()
"Send PATCH request with the updated profile note.
Ask for confirmation if length > 500 characters."
(interactive)
(let* ((note (mastodon-profile--note-remove-header))
(url (mastodon-http--api "accounts/update_credentials")))
(if (> (mastodon-toot--count-toot-chars note) 500)
(when (y-or-n-p "Note is over mastodon's max for profile notes (500). Proceed?")
(kill-buffer-and-window)
(mastodon-profile--user-profile-send-updated-do url note))
(kill-buffer-and-window)
(mastodon-profile--user-profile-send-updated-do url note))))
(defun mastodon-profile--user-profile-send-updated-do (url note)
"Send PATCH request with the updated profile NOTE to URL."
(let ((response (mastodon-http--patch url `(("note" . ,note)))))
(mastodon-http--triage response
(lambda () (message "Profile note updated!")))))
(defun mastodon-profile--update-preference (pref val &optional source)
"Update account PREF erence to setting VAL.
Both args are strings.
SOURCE means that the preference is in the `source' part of the account JSON."
(let* ((url (mastodon-http--api "accounts/update_credentials"))
(pref-formatted (if source (concat "source[" pref "]") pref))
(response (mastodon-http--patch url `((,pref-formatted . ,val)))))
(mastodon-http--triage response
(lambda ()
(mastodon-profile--fetch-server-account-settings)
(message "Account setting %s updated to %s!" pref val)))))
(defun mastodon-profile--get-pref (pref)
"Return PREF from `mastodon-profile-account-settings'."
(plist-get mastodon-profile-account-settings pref))
(defun mastodon-profile--update-preference-plist (pref val)
"Set local account preference plist preference PREF to VAL.
This is done after changing the setting on the server."
(setq mastodon-profile-account-settings
(plist-put mastodon-profile-account-settings pref val)))
;; used in toot.el
(defun mastodon-profile--fetch-server-account-settings-maybe ()
"Fetch account settings from the server.
Only do so if `mastodon-profile-account-settings' is nil."
(mastodon-profile--fetch-server-account-settings :no-force))
(defun mastodon-profile--fetch-server-account-settings (&optional no-force)
"Fetch basic account settings from the server.
Store the values in `mastodon-profile-account-settings'.
Run in `mastodon-mode-hook'.
If NO-FORCE, only fetch if `mastodon-profile-account-settings' is nil."
(unless
(and no-force
mastodon-profile-account-settings)
(let ((keys '(locked discoverable display_name bot))
(source-keys '(privacy sensitive language)))
(mapc (lambda (k)
(mastodon-profile--update-preference-plist
k
(mastodon-profile--get-json-value k)))
keys)
(mapc (lambda (sk)
(mastodon-profile--update-preference-plist
sk
(mastodon-profile--get-source-value sk)))
source-keys)
;; hack for max toot chars:
(mastodon-toot--get-max-toot-chars :no-toot)
(mastodon-profile--update-preference-plist 'max_toot_chars
mastodon-toot--max-toot-chars)
;; TODO: remove now redundant vars, replace with fetchers from the plist
(setq mastodon-toot--visibility (mastodon-profile--get-pref 'privacy)
mastodon-toot--content-nsfw (mastodon-profile--get-pref 'sensitive))
mastodon-profile-account-settings)))
(defun mastodon-profile--account-locked-toggle ()
"Toggle the locked status of your account.
Locked means follow requests have to be approved."
(interactive)
(mastodon-profile--toggle-account-key 'locked))
(defun mastodon-profile--account-discoverable-toggle ()
"Toggle the discoverable status of your account.
Discoverable means the account is listed in the server directory."
(interactive)
(mastodon-profile--toggle-account-key 'discoverable))
(defun mastodon-profile--account-bot-toggle ()
"Toggle the bot status of your account."
(interactive)
(mastodon-profile--toggle-account-key 'bot))
(defun mastodon-profile--account-sensitive-toggle ()
"Toggle the sensitive status of your account.
When enabled, statuses are marked as sensitive by default."
(interactive)
(mastodon-profile--toggle-account-key 'sensitive :source))
(defun mastodon-profile--toggle-account-key (key &optional source)
"Toggle the boolean account setting KEY.
SOURCE means the setting is located under \"source\" in the account JSON.
Current settings are fetched from the server."
(let* ((val (if source
(mastodon-profile--get-source-value key)
(mastodon-profile--get-json-value key)))
(prompt (format "Account setting %s is %s. Toggle?" key val)))
(if val
(when (y-or-n-p prompt)
(mastodon-profile--update-preference (symbol-name key) "false" source))
(when (y-or-n-p prompt)
(mastodon-profile--update-preference (symbol-name key) "true" source)))))
(defun mastodon-profile--edit-string-value (key)
"Edit the string for account preference KEY."
(let* ((val (mastodon-profile--get-json-value key))
(new-val
(read-string (format "Edit account setting %s: " key)
val)))
(mastodon-profile--update-preference (symbol-name key) new-val)))
(defun mastodon-profile--update-display-name ()
"Update display name for your account."
(interactive)
(mastodon-profile--edit-string-value 'display_name))
(defun mastodon-profile--make-meta-fields-params (fields)
"Construct a parameter query string from metadata alist FIELDS.
Returns an alist."
(let ((keys (cl-loop for count from 1 to 5
collect (cons (format "fields_attributes[%s][name]" count)
(format "fields_attributes[%s][value]" count)))))
(cl-loop for a-pair in keys
for b-pair in fields
append (list (cons (car a-pair)
(car b-pair))
(cons (cdr a-pair)
(cdr b-pair))))))
(defun mastodon-profile--update-meta-fields ()
"Prompt for new metadata fields information and PATCH the server."
(interactive)
(let* ((url (mastodon-http--api "accounts/update_credentials"))
(fields-updated (mastodon-profile--update-meta-fields-alist))
(params (mastodon-profile--make-meta-fields-params fields-updated))
(response (mastodon-http--patch url params)))
(mastodon-http--triage response
(lambda ()
(mastodon-profile--fetch-server-account-settings)
(message "Account setting %s updated to %s!"
"metadata fields" fields-updated)))))
(defun mastodon-profile--update-meta-fields-alist ()
"Prompt for new metadata fields information.
Returns the results as an alist."
(let ((fields-old (mastodon-profile--fields-get
nil
;; we must fetch the plaintext version:
(mastodon-profile--get-source-value 'fields))))
;; offer empty fields if user currently has less than four filled:
(while (< (length fields-old) 4)
(setq fields-old
(append fields-old '(("" . "")))))
(let ((alist
(cl-loop for f in fields-old
for x from 1 to 5
collect
(cons (read-string
(format "Metadata key [%s/4] (max. 255 chars): " x)
(car f))
(read-string
(format "Metadata value [%s/4] (max. 255 chars): " x)
(cdr f))))))
;; hack to avoiding using `string-limit', which req. 28.1:
(mapcar (lambda (x)
(cons (mastodon-profile--limit-to-255 (car x))
(mastodon-profile--limit-to-255 (cdr x))))
alist))))
(defun mastodon-profile--limit-to-255 (x)
"Limit string X to 255 chars max."
(if (> (length x) 255) (substring x 0 255) x))
;; used in tl.el
(defun mastodon-profile--get-preferences-pref (pref)
"Fetch PREF from the endpoint \"/preferences\".
This endpoint only holds a few preferences. For others, see
`mastodon-profile--update-preference' and its endpoint,
\"/accounts/update_credentials.\""
(alist-get pref
(mastodon-http--get-json
(mastodon-http--api "preferences"))))
(defun mastodon-profile--view-preferences ()
"View user preferences in another window."
(interactive)
(let* ((url (mastodon-http--api "preferences"))
(response (mastodon-http--get-json url))
(buf (get-buffer-create "*mastodon-preferences*")))
(with-mastodon-buffer buf #'special-mode :other-window
(mastodon-tl--set-buffer-spec (buffer-name buf) "preferences" nil)
(while response
(let ((el (pop response)))
(insert
(format "%-30s %s"
(prin1-to-string (car el))
(prin1-to-string (cdr el)))
"\n\n")))
(goto-char (point-min)))))
;; PROFILE VIEW DETAILS
(defun mastodon-profile--relationships-get (id)
"Fetch info about logged-in user's relationship to user with id ID."
(let* ((their-id id)
(args `(("id[]" . ,their-id)))
(url (mastodon-http--api "accounts/relationships")))
;; FIXME: not sure why we need to do this for relationships only!
(car (mastodon-http--get-json url args))))
(defun mastodon-profile--fields-get (&optional account fields)
"Fetch the fields vector (aka profile metadata) from profile of ACCOUNT.
Returns an alist.
FIELDS means provide a fields vector fetched by other means."
(let ((fields (or fields
(mastodon-profile--account-field account 'fields))))
(when fields
(mastodon-tl--map-alist-vals-to-alist 'name 'value fields))))
(defun mastodon-profile--fields-insert (fields)
"Format and insert field pairs (a.k.a profile metadata) in FIELDS."
(let* ((car-fields (mapcar #'car fields))
(left-width (cl-reduce
#'max (mapcar #'length car-fields))))
(mapconcat (lambda (field)
(mastodon-tl--render-text
(concat
(format "_ %s " (car field))
(make-string (- (+ 1 left-width) (length (car field))) ?_)
(format " :: %s" (cdr field)))
field)) ; hack to make links tabstops
fields "")))
(defun mastodon-profile--get-statuses-pinned (account)
"Fetch the pinned toots for ACCOUNT."
(let* ((id (mastodon-profile--account-field account 'id))
(args `(("pinned" . "true")))
(url (mastodon-http--api (format "accounts/%s/statuses" id))))
(mastodon-http--get-json url args)))
(defun mastodon-profile--insert-statuses-pinned (pinned-statuses)
"Insert each of the PINNED-STATUSES for a given account."
(mapc (lambda (pinned-status)
(insert (mastodon-tl--set-face
" :pinned: " 'success))
(mastodon-tl--toot pinned-status))
pinned-statuses))
(defun mastodon-profile--make-profile-buffer-for (account endpoint-type
update-function
&optional no-reblogs headers)
"Display profile of ACCOUNT, using ENDPOINT-TYPE and UPDATE-FUNCTION.
NO-REBLOGS means do not display boosts in statuses.
HEADERS means also fetch link headers for pagination."
(let* ((id (mastodon-profile--account-field account 'id))
(args `(("limit" . ,mastodon-tl--timeline-posts-count)))
(args (if no-reblogs (push '("exclude_reblogs" . "t") args) args))
(endpoint (format "accounts/%s/%s" id endpoint-type))
(url (mastodon-http--api endpoint))
(acct (mastodon-profile--account-field account 'acct))
(buffer (concat "*mastodon-" acct "-"
(if no-reblogs
(concat endpoint-type "-no-boosts")
endpoint-type)
"*"))
(response (if headers
(mastodon-http--get-response url args)
(mastodon-http--get-json url args)))
(json (if headers (car response) response))
(link-header (when headers
(mastodon-tl--get-link-header-from-response
(cdr response))))
(note (mastodon-profile--account-field account 'note))
(locked (mastodon-profile--account-field account 'locked))
(followers-count (mastodon-tl--as-string
(mastodon-profile--account-field
account 'followers_count)))
(following-count (mastodon-tl--as-string
(mastodon-profile--account-field
account 'following_count)))
(toots-count (mastodon-tl--as-string
(mastodon-profile--account-field
account 'statuses_count)))
(relationships (mastodon-profile--relationships-get id))
(requested-you (when (not (seq-empty-p relationships))
(alist-get 'requested_by relationships)))
(followed-by-you (when (not (seq-empty-p relationships))
(alist-get 'following relationships)))
(follows-you (when (not (seq-empty-p relationships))
(alist-get 'followed_by relationships)))
(followsp (or (equal follows-you 't) (equal followed-by-you 't)
(equal requested-you 't)))
(fields (mastodon-profile--fields-get account))
(pinned (mastodon-profile--get-statuses-pinned account))
(joined (mastodon-profile--account-field account 'created_at)))
(with-mastodon-buffer buffer #'mastodon-mode nil
(mastodon-profile-mode)
(setq mastodon-profile--account account)
(mastodon-tl--set-buffer-spec buffer
endpoint
update-function
link-header)
(let* ((inhibit-read-only t)
(is-statuses (string= endpoint-type "statuses"))
(is-followers (string= endpoint-type "followers"))
(is-following (string= endpoint-type "following"))
(endpoint-name (cond
(is-statuses (if no-reblogs
" TOOTS (no boosts)"
" TOOTS "))
(is-followers " FOLLOWERS ")
(is-following " FOLLOWING "))))
(insert
(propertize
(concat
"\n"
(mastodon-profile--image-from-account account 'avatar_static)
(mastodon-profile--image-from-account account 'header_static)
"\n"
(propertize (mastodon-profile--account-field
account 'display_name)
'face 'mastodon-display-name-face)
"\n"
(propertize (concat "@" acct)
'face 'default)
(if (equal locked t)
(concat " " (mastodon-tl--symbol 'locked))
"")
"\n " mastodon-tl--horiz-bar "\n"
;; profile note:
;; account here to enable tab-stops in profile note
(mastodon-tl--render-text note account)
;; meta fields:
(if fields
(concat "\n"
(mastodon-tl--set-face
(mastodon-profile--fields-insert fields)
'success))
"")
"\n"
;; Joined date:
(propertize
(mastodon-profile--format-joined-date-string joined)
'face 'success)
"\n\n")
'profile-json account)
;; insert counts
(mastodon-tl--set-face
(concat " " mastodon-tl--horiz-bar "\n"
" TOOTS: " toots-count " | "
"FOLLOWERS: " followers-count " | "
"FOLLOWING: " following-count "\n"
" " mastodon-tl--horiz-bar "\n\n")
'success)
;; insert relationship (follows)
(if followsp
(mastodon-tl--set-face
(concat (when (equal follows-you 't)
" | FOLLOWS YOU")
(when (equal followed-by-you 't)
" | FOLLOWED BY YOU")
(when (equal requested-you 't)
" | REQUESTED TO FOLLOW YOU")
"\n\n")
'success)
"") ; if no followsp we still need str-or-char-p for insert
;; insert endpoint
(mastodon-tl--set-face
(concat " " mastodon-tl--horiz-bar "\n"
endpoint-name "\n"
" " mastodon-tl--horiz-bar "\n")
'success))
(setq mastodon-tl--update-point (point))
(mastodon-media--inline-images (point-min) (point))
;; insert pinned toots first
(when (and pinned (equal endpoint-type "statuses"))
(mastodon-profile--insert-statuses-pinned pinned)
(setq mastodon-tl--update-point (point))) ;updates to follow pinned toots
(funcall update-function json)))
(goto-char (point-min))))
(defun mastodon-profile--format-joined-date-string (joined)
"Format a human-readable Joined string from timestamp JOINED.
JOINED is the `created_at' field in profile account JSON, and of
the format \"2000-01-31T00:00:00.000Z\"."
(format-time-string "Joined: %d %B %Y"
(parse-iso8601-time-string joined)))
(defun mastodon-profile--get-toot-author ()
"Open profile of author of toot under point.
If toot is a boost, opens the profile of the booster."
(interactive)
(mastodon-profile--make-author-buffer
(alist-get 'account (mastodon-profile--toot-json))))
(defun mastodon-profile--image-from-account (account img-type)
"Return a avatar image from ACCOUNT.
IMG-TYPE is the JSON key from the account data."
(let ((img (alist-get img-type account)))
(unless (equal img "/avatars/original/missing.png")
(mastodon-media--get-media-link-rendering img))))
(defun mastodon-profile--show-user (user-handle)
"Query for USER-HANDLE from current status and show that user's profile."
(interactive
(list
(if (and (not (mastodon-tl--profile-buffer-p))
(not (mastodon-tl--property 'toot-json :no-move)))
(message "Looks like there's no toot or user at point?")
(let ((user-handles (mastodon-profile--extract-users-handles
(mastodon-profile--toot-json))))
(completing-read "View profile of user [choose or enter any handle]: "
user-handles
nil ; predicate
'confirm)))))
(if (not (or
;; own profile has no need for toot-json test:
(equal user-handle (mastodon-auth--get-account-name))
(mastodon-tl--property 'toot-json :no-move)))
(message "Looks like there's no toot or user at point?")
(let ((account (mastodon-profile--lookup-account-in-status
user-handle (mastodon-profile--toot-json))))
(if account
(progn
(message "Loading profile of user %s..." user-handle)
(mastodon-profile--make-author-buffer account)
(message "'C-c C-c' to cycle profile views: toots, followers, following"))
(message "Cannot find a user with handle %S" user-handle)))))
(defun mastodon-profile--my-profile ()
"Show the profile of the currently signed in user."
(interactive)
(message "Loading your profile...")
(mastodon-profile--show-user (mastodon-auth--get-account-name)))
(defun mastodon-profile--account-field (account field)
"Return FIELD from the ACCOUNT.
FIELD is used to identify regions under `account'."
(cdr (assoc field account)))
(defun mastodon-profile--add-author-bylines (tootv)
"Convert TOOTV into a author-bylines and insert.
Also insert their profile note.
Used to view a user's followers and those they're following."
;;FIXME change the name of this fun now that we've edited what it does!
(let ((inhibit-read-only t))
(unless (seq-empty-p tootv)
(mapc (lambda (toot)
(let ((start-pos (point)))
(insert "\n"
(propertize
(mastodon-tl--byline-author `((account . ,toot))
:avatar)
'byline 't
'toot-id (alist-get 'id toot)
'base-toot-id (mastodon-tl--toot-id toot)
'toot-json toot))
(mastodon-media--inline-images start-pos (point))
(insert "\n"
(propertize
(mastodon-tl--render-text (alist-get 'note toot) nil)
'toot-json toot) '
"\n")))
tootv))))
(defun mastodon-profile--search-account-by-handle (handle)
"Return an account based on a user's HANDLE.
If the handle does not match a search return then retun NIL."
(let* ((handle (if (string= "@" (substring handle 0 1))
(substring handle 1 (length handle))
handle))
(args `(("q" . ,handle)))
(matching-account
(seq-remove
(lambda (x)
(not (string= (alist-get 'acct x) handle)))
(mastodon-http--get-json
(mastodon-http--api "accounts/search")
args))))
(when (equal 1 (length matching-account))
(elt matching-account 0))))
(defun mastodon-profile--account-from-id (user-id)
"Request an account object relating to a USER-ID from Mastodon."
(mastodon-http--get-json
(mastodon-http--api (format "accounts/%s" user-id))))
(defun mastodon-profile--extract-users-handles (status)
"Return all user handles found in STATUS.
These include the author, author of reblogged entries and any user mentioned."
(when status
(let ((this-account
(or (alist-get 'account status) ; status is a toot
status)) ; status is a user listing
(mentions (or (alist-get 'mentions (alist-get 'status status))
(alist-get 'mentions status)))
(reblog (or (alist-get 'reblog (alist-get 'status status))
(alist-get 'reblog status))))
(seq-filter
#'stringp
(seq-uniq
(seq-concatenate
'list
(list (alist-get 'acct this-account))
(mastodon-profile--extract-users-handles reblog)
(mastodon-tl--map-alist 'acct mentions)))))))
(defun mastodon-profile--lookup-account-in-status (handle status)
"Return account for HANDLE using hints in STATUS if possible."
(let* ((this-account (alist-get 'account status))
(reblog-account (alist-get 'account (alist-get 'reblog status)))
(mention-id (seq-some
(lambda (mention)
(when (string= handle
(alist-get 'acct mention))
(alist-get 'id mention)))
(alist-get 'mentions status))))
(cond ((string= handle
(alist-get 'acct this-account))
this-account)
((string= handle
(alist-get 'acct reblog-account))
reblog-account)
(mention-id
(mastodon-profile--account-from-id mention-id))
(t
(mastodon-profile--search-account-by-handle handle)))))
(defun mastodon-profile--remove-user-from-followers (&optional id)
"Remove a user from your followers.
Optionally provide the ID of the account to remove."
(interactive)
(let* ((account (unless id (mastodon-tl--property 'toot-json :no-move)))
(id (or id (alist-get 'id account)))
(handle (if account
(alist-get 'acct account)
(let ((account
(mastodon-profile--account-from-id id)))
(alist-get 'acct account))))
(url (mastodon-http--api
(format "accounts/%s/remove_from_followers" id))))
(when (y-or-n-p (format "Remove follower %s? " handle))
(let ((response (mastodon-http--post url)))
(mastodon-http--triage response
(lambda ()
(message "Follower %s removed!" handle)))))))
(defun mastodon-profile--remove-from-followers-at-point ()
"Prompt for a user in the item at point and remove from followers."
(interactive)
(let* ((handles (mastodon-profile--extract-users-handles
(mastodon-profile--toot-json)))
(handle (completing-read "Remove from followers: "
handles nil))
(account (mastodon-profile--lookup-account-in-status
handle (mastodon-profile--toot-json)))
(id (alist-get 'id account)))
(mastodon-profile--remove-user-from-followers id)))
(defun mastodon-profile--remove-from-followers-list ()
"Select a user from your followers and remove from followers.
Currently limited to 100 handles. If not found, try
`mastodon-search--search-query'."
(interactive)
(let* ((endpoint (format "accounts/%s/followers"
(mastodon-auth--get-account-id)))
(url (mastodon-http--api endpoint))
(response (mastodon-http--get-json url
`(("limit" . "100"))))
(handles (mastodon-tl--map-alist-vals-to-alist 'acct 'id response))
(choice (completing-read "Remove from followers: "
handles))
(id (alist-get choice handles nil nil 'equal)))
(mastodon-profile--remove-user-from-followers id)))
(defun mastodon-profile--add-private-note-to-account ()
"Add a private note to an account.
Can be called from a profile page or normal timeline.
Send an empty note to clear an existing one."
(interactive)
(mastodon-profile--add-or-view-private-note
'mastodon-profile--post-private-note-to-account
"add a note to"))
(defun mastodon-profile--post-private-note-to-account (id handle note-old)
"POST a private note onto an account ID with user HANDLE on the server.
NOTE-OLD is the text of any existing note."
(let* ((note (read-string (format "Add private note to account %s: " handle)
note-old))
(params `(("comment" . ,note)))
(url (mastodon-http--api (format "accounts/%s/note" id)))
(response (mastodon-http--post url params)))
(mastodon-http--triage response
(lambda ()
(message "Private note on %s added!" handle)))))
(defun mastodon-profile--view-account-private-note ()
"Display the private note about a user."
(interactive)
(mastodon-profile--add-or-view-private-note
'mastodon-profile--display-private-note
"view private note of"
:view))
(defun mastodon-profile--display-private-note (note)
"Display private NOTE in a temporary buffer."
(with-output-to-temp-buffer "*mastodon-profile-private-note*"
(let ((inhibit-read-only t))
(princ note))))
(defun mastodon-profile--profile-json ()
"Return the profile-json property if we are in a profile buffer."
(when (mastodon-tl--profile-buffer-p)
(save-excursion
(goto-char (point-min))
(or (mastodon-tl--property 'profile-json :no-move)
(error "No profile data found")))))
(defun mastodon-profile--add-or-view-private-note (action-fun &optional message view)
"Add or view a private note for an account.
ACTION-FUN does the adding or viewing, MESSAGE is a prompt for
`mastodon-tl--interactive-user-handles-get', VIEW is a flag."
(let* ((profile-json (mastodon-profile--profile-json))
(handle (if (mastodon-tl--profile-buffer-p)
(alist-get 'acct profile-json)
(mastodon-tl--interactive-user-handles-get message)))
(account (if (mastodon-tl--profile-buffer-p)
profile-json
(mastodon-profile--search-account-by-handle handle)))
(id (alist-get 'id account))
(relationships (mastodon-profile--relationships-get id))
(note (alist-get 'note relationships)))
(if view
(if (string-empty-p note)
(message "No private note for %s" handle)
(funcall action-fun note))
(funcall action-fun id handle note))))
(defun mastodon-profile--show-familiar-followers ()
"Show a list of familiar followers.
Familiar followers are accounts that you follow, and that follow
the given account."
(interactive)
(let* ((profile-json (mastodon-profile--profile-json))
(handle
(if (mastodon-tl--profile-buffer-p)
(alist-get 'acct profile-json)
(mastodon-tl--interactive-user-handles-get "show familiar followers of")))
(account (if (mastodon-tl--profile-buffer-p)
profile-json
(mastodon-profile--search-account-by-handle handle)))
(id (alist-get 'id account)))
(mastodon-profile--get-familiar-followers id)))
(defun mastodon-profile--get-familiar-followers (id)
"Return JSON data of familiar followers for account ID."
;; the server can handle multiple IDs, but for now we just handle one.
(let* ((params `(("id" . ,id)))
(url (mastodon-http--api "accounts/familiar_followers"))
(json (mastodon-http--get-json url params))
(accounts (alist-get 'accounts (car json))) ; first id result
(handles (mastodon-tl--map-alist 'acct accounts)))
(if (null handles)
(message "Looks like there are no familiar followers for this account")
(let ((choice (completing-read "Show profile of user: "
handles)))
(mastodon-profile--show-user choice)))))
(provide 'mastodon-profile)
;;; mastodon-profile.el ends here
(define-package "mastodon" "20230525.1812" "Client for fediverse services using the Mastodon API"
'((emacs "27.1")
(request "0.3.0")
(persist "0.4")
(ts "0.3"))
:commit "dfc40c0bc2c8f1653ca71335b6ad99230f38067e" :authors
'(("Johnson Denen" . "johnson.denen@gmail.com")
("Marty Hiatt" . "martianhiatus@riseup.net"))
:maintainers
'(("Marty Hiatt" . "martianhiatus@riseup.net"))
:maintainer
'("Marty Hiatt" . "martianhiatus@riseup.net")
:url "https://codeberg.org/martianh/mastodon.el")
;; Local Variables:
;; no-byte-compile: t
;; End:
;;; mastodon-notifications.el --- Notification functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-notification.el provides notification functions for Mastodon.
;;; Code:
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--get-params-async-json "mastodon-http")
(autoload 'mastodon-http--post "mastodon-http")
(autoload 'mastodon-http--triage "mastodon-http")
(autoload 'mastodon-media--inline-images "mastodon-media")
(autoload 'mastodon-notifications-get "mastodon")
(autoload 'mastodon-tl--byline "mastodon-tl")
(autoload 'mastodon-tl--byline-author "mastodon-tl")
(autoload 'mastodon-tl--clean-tabs-and-nl "mastodon-tl")
(autoload 'mastodon-tl--content "mastodon-tl")
(autoload 'mastodon-tl--field "mastodon-tl")
(autoload 'mastodon-tl--find-property-range "mastodon-tl")
(autoload 'mastodon-tl--has-spoiler "mastodon-tl")
(autoload 'mastodon-tl--init "mastodon-tl")
(autoload 'mastodon-tl--insert-status "mastodon-tl")
(autoload 'mastodon-tl--property "mastodon-tl")
(autoload 'mastodon-tl--reload-timeline-or-profile "mastodon-tl")
(autoload 'mastodon-tl--spoiler "mastodon-tl")
(autoload 'mastodon-tl--toot-id "mastodon-tl")
(autoload 'mastodon-tl--update "mastodon-tl")
(autoload 'mastodon-views--view-follow-requests "mastodon-views")
(defvar mastodon-tl--buffer-spec)
(defvar mastodon-tl--display-media-p)
(defvar mastodon-mode-map)
(defvar mastodon-notifications--types-alist
'(("follow" . mastodon-notifications--follow)
("favourite" . mastodon-notifications--favourite)
("reblog" . mastodon-notifications--reblog)
("mention" . mastodon-notifications--mention)
("poll" . mastodon-notifications--poll)
("follow_request" . mastodon-notifications--follow-request)
("status" . mastodon-notifications--status)
("update" . mastodon-notifications--edit))
"Alist of notification types and their corresponding function.")
(defvar mastodon-notifications--response-alist
'(("Followed" . "you")
("Favourited" . "your status from")
("Boosted" . "your status from")
("Mentioned" . "you")
("Posted a poll" . "that has now ended")
("Requested to follow" . "you")
("Posted" . "a post")
("Edited" . "a post from"))
"Alist of subjects for notification types.")
(defvar mastodon-notifications--map
(let ((map
(copy-keymap mastodon-mode-map)))
(define-key map (kbd "a") #'mastodon-notifications--follow-request-accept)
(define-key map (kbd "j") #'mastodon-notifications--follow-request-reject)
(define-key map (kbd "C-k") #'mastodon-notifications--clear-current)
(keymap-canonicalize map))
"Keymap for viewing notifications.")
(defun mastodon-notifications--byline-concat (message)
"Add byline for TOOT with MESSAGE."
(concat
" "
(propertize message 'face 'highlight)
" "
(cdr (assoc message mastodon-notifications--response-alist))))
(defun mastodon-notifications--follow-request-process (&optional reject)
"Process the follow request at point.
With no argument, the request is accepted. Argument REJECT means
reject the request. Can be called in notifications view or in
follow-requests view."
(if (not (mastodon-tl--find-property-range 'toot-json (point)))
(message "No follow request at point?")
(let* ((toot-json (mastodon-tl--property 'toot-json))
(f-reqs-view-p (string= "follow_requests"
(plist-get mastodon-tl--buffer-spec 'endpoint)))
(f-req-p (or (string= "follow_request" (alist-get 'type toot-json)) ;notifs
f-reqs-view-p)))
(if f-req-p
(let* ((account (or (alist-get 'account toot-json) ;notifs
toot-json)) ;f-reqs
(id (alist-get 'id account))
(handle (alist-get 'acct account))
(name (alist-get 'username account)))
(if id
(let ((response
(mastodon-http--post
(concat
(mastodon-http--api "follow_requests")
(format "/%s/%s" id (if reject
"reject"
"authorize"))))))
(mastodon-http--triage response
(lambda ()
(if f-reqs-view-p
(mastodon-views--view-follow-requests)
(mastodon-tl--reload-timeline-or-profile))
(message "Follow request of %s (@%s) %s!"
name handle (if reject
"rejected"
"accepted")))))
(message "No account result at point?")))
(message "No follow request at point?")))))
(defun mastodon-notifications--follow-request-accept ()
"Accept a follow request.
Can be called in notifications view or in follow-requests view."
(interactive)
(mastodon-notifications--follow-request-process))
(defun mastodon-notifications--follow-request-reject ()
"Reject a follow request.
Can be called in notifications view or in follow-requests view."
(interactive)
(mastodon-notifications--follow-request-process :reject))
(defun mastodon-notifications--mention (note)
"Format for a `mention' NOTE."
(mastodon-notifications--format-note note 'mention))
(defun mastodon-notifications--follow (note)
"Format for a `follow' NOTE."
(mastodon-notifications--format-note note 'follow))
(defun mastodon-notifications--follow-request (note)
"Format for a `follow-request' NOTE."
(mastodon-notifications--format-note note 'follow-request))
(defun mastodon-notifications--favourite (note)
"Format for a `favourite' NOTE."
(mastodon-notifications--format-note note 'favourite))
(defun mastodon-notifications--reblog (note)
"Format for a `boost' NOTE."
(mastodon-notifications--format-note note 'boost))
(defun mastodon-notifications--status (note)
"Format for a `status' NOTE.
Status notifications are given when
`mastodon-tl--enable-notify-user-posts' has been set."
(mastodon-notifications--format-note note 'status))
(defun mastodon-notifications--poll (note)
"Format for a `poll' NOTE."
(mastodon-notifications--format-note note 'poll))
(defun mastodon-notifications--edit (note)
"Format for an `edit' NOTE."
(mastodon-notifications--format-note note 'edit))
(defun mastodon-notifications--format-note (note type)
"Format for a NOTE of TYPE."
(let ((id (alist-get 'id note))
(status (mastodon-tl--field 'status note))
(follower (alist-get 'username (alist-get 'account note))))
(mastodon-notifications--insert-status
(cond ((or (equal type 'follow)
(equal type 'follow-request))
;; Using reblog with an empty id will mark this as something
;; non-boostable/non-favable.
(cons '(reblog (id . nil)) note))
;; reblogs/faves use 'note' to process their own json
;; not the toot's. this ensures following etc. work on such notifs
((or (equal type 'favourite)
(equal type 'boost))
note)
(t
status))
(if (or (equal type 'follow)
(equal type 'follow-request))
(propertize (if (equal type 'follow)
"Congratulations, you have a new follower!"
(format "You have a follow request from... %s"
follower))
'face 'default)
(mastodon-tl--clean-tabs-and-nl
(if (mastodon-tl--has-spoiler status)
(mastodon-tl--spoiler status)
(mastodon-tl--content status))))
(if (or (equal type 'follow)
(equal type 'follow-request)
(equal type 'mention))
'mastodon-tl--byline-author
(lambda (_status)
(mastodon-tl--byline-author
note)))
(lambda (_status)
(mastodon-notifications--byline-concat
(cond ((equal type 'boost)
"Boosted")
((equal type 'favourite)
"Favourited")
((equal type 'follow-request)
"Requested to follow")
((equal type 'follow)
"Followed")
((equal type 'mention)
"Mentioned")
((equal type 'status)
"Posted")
((equal type 'poll)
"Posted a poll")
((equal type 'edit)
"Edited"))))
id
(when (or (equal type 'favourite)
(equal type 'boost))
status))))
(defun mastodon-notifications--insert-status (toot body
author-byline action-byline id
&optional base-toot)
"Display the content and byline of timeline element TOOT.
BODY will form the section of the toot above the byline.
AUTHOR-BYLINE is an optional function for adding the author
portion of the byline that takes one variable. By default it is
`mastodon-tl--byline-author'.
ACTION-BYLINE is also an optional function for adding an action,
such as boosting favouriting and following to the byline. It also
takes a single function. By default it is
`mastodon-tl--byline-boosted'.
ID is the notification's own id, which is attached as a property.
If the status is a favourite or a boost, BASE-TOOT is the JSON
of the toot responded to."
(when toot ; handle rare blank notif server bug
(mastodon-tl--insert-status toot body author-byline action-byline id base-toot)))
(defun mastodon-notifications--by-type (note)
"Filters NOTE for those listed in `mastodon-notifications--types-alist'."
(let* ((type (mastodon-tl--field 'type note))
(fun (cdr (assoc type mastodon-notifications--types-alist)))
(start-pos (point)))
(when fun
(funcall fun note)
(when mastodon-tl--display-media-p
(mastodon-media--inline-images start-pos (point))))))
(defun mastodon-notifications--timeline (json)
"Format JSON in Emacs buffer."
(if (seq-empty-p json)
(message "Looks like you have no (more) notifications for the moment.")
(mapc #'mastodon-notifications--by-type json)
(goto-char (point-min))))
(defun mastodon-notifications--get-mentions ()
"Display mention notifications in buffer."
(interactive)
(mastodon-notifications-get "mention" "mentions"))
(defun mastodon-notifications--get-favourites ()
"Display favourite notifications in buffer."
(interactive)
(mastodon-notifications-get "favourite" "favourites"))
(defun mastodon-notifications--get-boosts ()
"Display boost notifications in buffer."
(interactive)
(mastodon-notifications-get "reblog" "boosts"))
(defun mastodon-notifications--get-polls ()
"Display poll notifications in buffer."
(interactive)
(mastodon-notifications-get "poll" "polls"))
(defun mastodon-notifications--get-statuses ()
"Display status notifications in buffer.
Status notifications are created when you call
`mastodon-tl--enable-notify-user-posts'."
(interactive)
(mastodon-notifications-get "status" "statuses"))
(defun mastodon-notifications--filter-types-list (type)
"Return a list of notification types with TYPE removed."
(let ((types
(mapcar #'car mastodon-notifications--types-alist)))
(remove type types)))
(defun mastodon-notifications--clear-all ()
"Clear all notifications."
(interactive)
(when (y-or-n-p "Clear all notifications?")
(let ((response
(mastodon-http--post (mastodon-http--api "notifications/clear"))))
(mastodon-http--triage
response (lambda ()
(when mastodon-tl--buffer-spec
(mastodon-tl--reload-timeline-or-profile))
(message "All notifications cleared!"))))))
(defun mastodon-notifications--clear-current ()
"Dismiss the notification at point."
(interactive)
(let* ((id (or (mastodon-tl--property 'toot-id)
(mastodon-tl--field 'id
(mastodon-tl--property 'toot-json))))
(response
(mastodon-http--post (mastodon-http--api
(format "notifications/%s/dismiss" id)))))
(mastodon-http--triage
response (lambda ()
(when mastodon-tl--buffer-spec
(mastodon-tl--reload-timeline-or-profile))
(message "Notification dismissed!")))))
(provide 'mastodon-notifications)
;;; mastodon-notifications.el ends here
;;; mastodon-media.el --- Functions for inlining Mastodon media -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-media.el provides functions for inlining media.
;; Known bug gnutls -12 when trying to access images on some systems.
;; It looks like their may be a version mismatch between the encryption
;; required by the server and client.
;;; Code:
(require 'url-cache)
(autoload 'mastodon-tl--propertize-img-str-or-url "mastodon-tl")
(defvar url-show-status)
(defvar mastodon-tl--shr-image-map-replacement)
(defgroup mastodon-media nil
"Inline Mastadon media."
:prefix "mastodon-media-"
:group 'mastodon)
(defcustom mastodon-media--avatar-height 20
"Height of the user avatar images (if shown)."
:type 'integer)
(defcustom mastodon-media--preview-max-height 250
"Max height of any media attachment preview to be shown in timelines."
:type 'integer)
(defcustom mastodon-media--enable-image-caching nil
"Whether images should be cached."
:type 'boolean)
(defvar mastodon-media--generic-avatar-data
(base64-decode-string
"iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAACXBIWXMAAAsTAAALEwEAmpwYAAAA
B3RJTUUH4QUIFCg2lVD1hwAAABZ0RVh0Q29tbWVudABHZW5lcmljIGF2YXRhcsyCnMsAAAcGSURB
VHja7dzdT1J/HAfwcw7EQzMKW0pGRMK4qdRZbdrs6aIRbt506V1b/AV1U2td9l9UXnmhW6vgwuko
SbcOD/a0RB4CCRCRg0AIR4Hz8LvgN2cKCMI5wOH7uXBuugO+eH8+fM/3HIFpmoZAVVYIIABYAAtg
ASyABbAAAcACWAALYAEsgAUIABbAAlgAC2ABLEAAsAAWwAJYAAtgAQKAxUjxm+R50DRN0zRFUf+8
kggCwzAMwwDrfyOSJGmattlsdrvd5XLlcrndnyoUir6+vpGRkZMnT/J4vIarwY26MaTAZLVap6en
fT7f9vY2QRA7Ozv/vJJ8vkgk4vP5XV1dWq1Wq9VKpdIGkjUGi6IoFEWnp6ddLlcymSRJsvzv83g8
kUikUCi0Wq1Opzt16lS7YBEE8ebNG6PRiGHYoUwHyW7cuPHo0SOlUsl9LIIgXrx4Ybfb//79e7Qj
CIXC3t7ex48fX7lyhctYBSkURTOZTC3H4fF4SqXy6dOnLHuxh0VR1PPnz2uX2uv17Nmzy5cvc21R
StP0q1ev7HZ7XaQgCCJJ0u/3T0xMBINBrmGhKGo0Go88p0p5Wa1Wg8GQSqW4g0XT9NTUFIZhdT9y
Npudn59nLVwIO7FyuVxVrRIqr1AoZDab2QkXG1hTU1PJZJKhg5MkOT8/HwqFuIBF07TP52MoVrvh
YqLHG4BlsVi2t7cZfQiSJB0OBwudyDiWzWYjCILpR1lZWeECltPp3LeXwEQFg8FoNNryWPl8noVp
ws6jgG1lgAWwuI914cIFPp/xnX6ZTCYSiVoeq7+/n4U/Q61Wy+Xylse6desWC8kaGBiQSCQtjyWR
SGQyGY/HY+4hpFJpV1cXRwa8TqdjtBOHh4fVajVHsLRarVKpZChcUqn07t27LPQgS1gSiUSn04nF
4rofGYbh4eHhgYEBTq2ztFrtyMhI3ZtRo9GMjY2xEyv2sCQSiV6vV6lUdWzGzs7O8fHxwcFBDq7g
5XL5kydPent76+LV2dmp1+vv37/P5gqe7SvSDofj5cuXteydwjAslUr1ev2DBw9YPt1pwL0ODodj
YmLCYrEcYZ8LhmGNRjM+Ps5yphqGBUFQKBQyGo0mk2l1dTWfz5MkSVFUPp8/+GSEQiEMw8eOHYNh
uLu7e2hoaGxsjM05tbfYvpkNx/FQKBSJRCAI6unpwTBsbW0tmUwWbtc6mCMEQSAIOn78+Llz586f
P9/T05PL5QKBgEKh4GyyCkZfvnwJhULhcHhzczOTyRRuYMtms/l8PpPJZDKZnZ2dvc9HIBCIxeIT
J04Uvil87ejoOH36tEwm02g0V69evXjxIkewCkZer/fr16+/f/+OxWKlrvQQBEEQxL7dYQRBhEJh
0fNwBEHEYrFMJlOpVP39/RqNhgU1prAKTDMzMy6XKxqNJhIJptY+CHLmzBmZTHbp0qXbt2+rVKpW
wtplWl5eDofDTF803Bs0tVrNKFmdsXAcn52dnZ2dDQaD7DAVJRsdHb1z507dT93rhoXj+MrKytzc
3NLSEnNNVyHZ2bNnr127NjQ0NDg4WEey+mDhOP7u3bu5ubkyI5z9iMnl8nv37o2OjgoEgmbBisVi
r1+/ttlsjQ1UmYg9fPiwo6OjwVg4jn///v3Dhw/Ly8vNEKiiXhKJpK+vT6fT1d6S/FqkUBSdnJz0
+/1QsxZFUclkEkXReDxOkuT169dr8TpisnAcN5lMb9++ZfP+11pKIBAUdgpv3rx55BGGtIMUBEG5
XM7tdhsMhoWFhb3/S8UsVitK1curaqzV1dX379+3nNQ+r42NjSPsPlaH5fP5mnyiV+Ll9XonJyfD
4XC1XkhVDTgzM/Pz50+oxSubzX779u3z58/VLneQyqUMBsOnT5+acz1V7XoiHo9//PjRZDKl0+n6
Y3k8HrPZ3Gxr9Fq81tfXl5aWAoFA5cO+IqxIJFLYSIA4VARBuN3uxcXFyoc9v5IGNJvNVquVAw14
sBktFkt3d7dUKq3k5BGpJFYLCwucacCizZhIJCoJF3JorBYXF//8+QNxtAiCKFwiqKRvkEPnOoqi
HGvAfeFKJBIVTnqkfKx+/PjBsbleKlwej6cmLI/H43A4OByr3XClUimn03louMphra2teb1eqA0q
m836fL6tra0jYkUiEb/fz8k3waLhikQiXq+3/NtiSayNjY1fv35BbVP5fN7pdG5tbR0Fy+12c360
Hxzz5a8KI6V6EMMwzo/2fZ2YTqej0WgqlSoVLqRUDwYCAajNiqKoYDBYphOLY8ViscItVG1VJEmu
r6+XeU8sjhWPxzc3N9sNiyAIDMOqS1YbDqwKx1YRrFQqxc7HJDRnpdPpUuEqgoVhWL0+i6hFz6tL
ja3iM4u1zw1qwhlfJihI0bfCNhxYe4NSqg3/A862hQAbrdtHAAAAAElFTkSuQmCC")
"The PNG data for a generic 100x100 avatar.")
(defvar mastodon-media--generic-broken-image-data
(base64-decode-string
"iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAAsTAAALEwEAmpwYAAAA
B3RJTUUH4QUIFQUVFt+0LQAAABZ0RVh0Q29tbWVudABHZW5lcmljIGF2YXRhcsyCnMsAAAdoSURB
VHja7d1NSFRrAIfx//iB6ZDSMJYVkWEk0ceYFUkkhhQlEUhEg0FlC1eBoRTUwlbRok0TgRQURZAE
FgpjJmFajpK4kggxpXHRQEGWUJZizpy7uPfC5eKiV+dD5zw/mN05jrxnnjnfcxyWZVkCMKc0SXI4
HIwEMIcUhgAgEIBAAAIBCAQgEIBAAAIBCAQgEAAEAhAIQCAAgQAEAhAIQCAAgQA2kBaNP8Jt7ViM
onErOWsQgEAAAgEIBCAQgEAAAgEIBCAQgEAAEAhAIACBAAQCEAhAIACBAAQCEAhAIAAIBCAQgEAA
AgEIBCAQgEAAAgEIBACBAAQCEAhAIACBAAQCEAhAIACBAAQCgEAAAgEIBCAQgECAxSyNIYitz58/
a3BwUIODgxoZGVEoFFIoFNK3b980NTWlX79+SZIyMzOVlZWlVatWae3atSooKJDH49HOnTvl8XiU
ksJ3WSI4LMuyHA7Hgv6IZVmM5D8mJyf1/PlzdXZ2qrOzU8FgcMF/0+126+DBg6qqqlJFRYXS0vhe
+6MP9wI/1wQSJeFwWH6/X01NTWpra9PU1FTM3isvL0/nz5/XuXPntHz5ciqIcSCy/v50L+hlV+Pj
49a1a9esdevWLXgMTV8ul8u6c+eOFYlELMwtKmNNIOa+fv1qXbp0yXI6nXEP4/+v0tJS6+PHj9RA
IIk3PT1tXb161crOzk54GP995ebmWt3d3RRBIInj9/utgoKCRRXGf18ZGRmW3++niigHwk56PHf4
Yiw9PV0dHR0qLy9nD52jWAQylxUrVmhgYEAbN24kkCgsM84+JZmJiQmdPn1akUiEweBE4eL/NsrN
zVVZWZlKSkpUWFioTZs2yeVyKTs7W7Ozs5qYmNDExITev3+v/v5+9fX1qb+/f8FjevPmTdXW1rIG
IZDFN9gbNmyQ1+uV1+uVx+MxXlAjIyNqbGzU3bt39fPnz3n9vytXrlQwGJTT6SQQThQm/ohIamqq
VVlZaXV1dUXtPT98+GCVlZXNe7n4fD6OYnGYN7GDnZ6ebtXU1FhjY2Mxed9IJGLV19fPa7kUFRUR
CIEkZrAdDod15syZmIXxf7W1tfNaNqOjowSygBdHseZh7969GhgY0IMHD5Sfnx+X97xx44Z2795t
PF93dzcLjMO88TvHcP/+ffX19WnXrl3xXVApKbp9+7bxfSFv3rxhwRFI7B07dkxDQ0Oqrq5O2P9Q
XFysffv2Gc0zOjrKwiOQ2Hv69Kny8vIS/n8cP37caPqxsTEWHoHYa//HxPfv3xk0ArGP1atXG03/
7z3vIBBbyM3NNZo+KyuLQSMQ+5icnDSaPicnh0EjEPsYHh42mp7L3gnEVnp6eoymLyoqYtAIxD4e
PXpkNP3+/fsZtAXgcvclpL29XUeOHPnj6Z1Op8bHx7Vs2TJ7fri5o9A+ZmZmdPHiRaN5vF6vbeNg
E8tmGhoaNDQ0ZPTteeHCBQaOQJLfkydPdP36daN5Tp48qc2bNzN47IMkt9evX+vw4cOanp7+43ly
cnI0PDy8KK4dYx8EMRMIBHT06FGjOCTJ5/PZPg42sZJce3u7Dh06pB8/fhjNV11dndBL8tnEYhMr
5lpaWuT1evX792+j+YqLixUIBLj+ik2s5NXc3KwTJ04Yx5Gfn69nz54RB5tYyaupqUlVVVWanZ01
ms/tdqujo4P9DgJJXg8fPtSpU6cUDoeN43j58qUKCwsZRAJJTvfu3dPZs2eNf0/X7Xarq6tL27dv
ZxAJJDn5fD7V1NQYx7FmzRq9evVK27ZtYxAJJDk1NDSorq7O+ChgQUGBent7tWXLFgYxxniecILU
1dXJ5/MZz7d161a9ePHC+N50sAZZMq5cuTKvOEpKStTT00McccSJwji7devWvJ7bceDAAbW2ttr6
cQbGH26eD7K0BAIBlZeXG5/nqKioUEtLizIyMhhEAklOX758kcfj0adPn4zXHG1tbcSRoEDYB4mT
y5cvG8exZ88etba2Egf7IMnt7du32rFjh9G5jvz8fA0MDBj/UBxYgyw5jY2NRnGkpqaqubmZOBYB
AomxmZkZPX782Gie+vr6uD9/BGxiJURvb69KS0v/ePrMzEyFQiG5XC4Gj02s5BcIBIymr6ysJA42
sezj3bt3RtObPv8DBLKkBYNBo+m5r4NAbCUUChlNv379egaNQOzD9FdJ2P8gEFsxfQQaFyMuLhzm
jfUAG45tOBw2fhY6ojP2rEGWwiqdONjEAggEIBCAQAACAUAgAIEA0cIPx8UYJ1FZgwAEAhAIAAIB
CAQgEIBAAAIBFiNOFMaY6V1tnFhkDQIQCEAgAIEABAKAQAACAQgEIBCAQAACAQgEIBCAQABIXO4e
c1y+zhoEIBCAQAAQCEAgAIEABAIQCEAgAIEABAIQCEAgAAgEIBCAQAACAQgEIBCAQAACAQgEAIEA
BAIQCEAgAIEABAIsJVH58WqHw8FIgjUIQCAACAQgEIBAAAIBCAQgEIBAAAIBCAQgEAAEAhAIQCBA
fKRJkmVZjAQwh78A6vCRWJE8K+8AAAAASUVORK5CYII=")
"The PNG data for a generic 200x200 \"broken image\" view.")
(defun mastodon-media--process-image-response
(status-plist marker image-options region-length url)
"Callback function processing the url retrieve response for URL.
STATUS-PLIST is the usual plist of status events as per `url-retrieve'.
IMAGE-OPTIONS are the precomputed options to apply to the image.
MARKER is the marker to where the response should be visible.
REGION-LENGTH is the length of the region that should be replaced
with the image."
(when (marker-buffer marker) ; only if the buffer hasn't been kill in the meantime
(let ((url-buffer (current-buffer))
(is-error-response-p (eq :error (car status-plist))))
(unwind-protect
(let* ((data (unless is-error-response-p
(goto-char (point-min))
(search-forward "\n\n")
(buffer-substring (point) (point-max))))
(image (when data
(apply #'create-image data
(if (version< emacs-version "27.1")
(when image-options 'imagemagick)
nil) ; inbuilt scaling in 27.1
t image-options))))
(when mastodon-media--enable-image-caching
(unless (url-is-cached url) ; cache if not already cached
(url-store-in-cache url-buffer)))
(with-current-buffer (marker-buffer marker)
;; Save narrowing in our buffer
(let ((inhibit-read-only t))
(save-restriction
(widen)
(put-text-property marker
(+ marker region-length) 'media-state 'loaded)
(when image
;; We only set the image to display if we could load
;; it; we already have set a default image when we
;; added the tag.
(put-text-property marker (+ marker region-length)
'display image))
;; We are done with the marker; release it:
(set-marker marker nil)))
(kill-buffer url-buffer)))))))
(defun mastodon-media--load-image-from-url (url media-type start region-length)
"Take a URL and MEDIA-TYPE and load the image asynchronously.
MEDIA-TYPE is a symbol and either `avatar' or `media-link.'
START is the position where we start loading the image.
REGION-LENGTH is the range from start to propertize."
(let ((image-options (when (or (image-type-available-p 'imagemagick)
(image-transforms-p)) ; inbuilt scaling in 27.1
(cond
((eq media-type 'avatar)
`(:height ,mastodon-media--avatar-height))
((eq media-type 'media-link)
`(:max-height ,mastodon-media--preview-max-height))))))
(let ((buffer (current-buffer))
(marker (copy-marker start))
;; Keep url.el from spamming us with messages about connecting to hosts:
(url-show-status nil))
(condition-case nil
;; catch any errors in url-retrieve so as to not abort
;; whatever called us
(if (and mastodon-media--enable-image-caching
(url-is-cached url))
;; if image url is cached, decompress and use it
(with-current-buffer (url-fetch-from-cache url)
(set-buffer-multibyte nil)
(goto-char (point-min))
(zlib-decompress-region (goto-char (search-forward "\n\n")) (point-max))
(mastodon-media--process-image-response nil marker image-options region-length url))
;; else fetch as usual and process-image-response will cache it
(url-retrieve url
#'mastodon-media--process-image-response
(list marker image-options region-length url)))
(error (with-current-buffer buffer
;; TODO: Consider adding retries
(put-text-property marker
(+ marker region-length)
'media-state
'loading-failed)
:loading-failed))))))
(defun mastodon-media--select-next-media-line (end-pos)
"Find coordinates of the next media to load before END-POS.
Returns the list of (`start' . `end', `media-symbol') points of
that line and string found or nil no more media links were
found."
(let ((next-pos (point)))
(while (and (setq next-pos (next-single-property-change next-pos 'media-state))
(or (not (eq 'needs-loading (get-text-property next-pos 'media-state)))
(null (get-text-property next-pos 'media-url))
(null (get-text-property next-pos 'media-type))))
;; do nothing - the loop will proceed
)
(when (and next-pos (< next-pos end-pos))
(let ((media-type (get-text-property next-pos 'media-type)))
(cond
;; Avatars are just one character in the buffer
((eq media-type 'avatar)
(list next-pos (+ next-pos 1) 'avatar))
;; Media links are 5 character ("[img]")
((eq media-type 'media-link)
(list next-pos (+ next-pos 5) 'media-link)))))))
(defun mastodon-media--valid-link-p (link)
"Check if LINK is valid.
Checks to make sure the missing string has not been returned."
(and link
(> (length link) 8)
(or (string= "http://" (substring link 0 7))
(string= "https://" (substring link 0 8)))))
(defun mastodon-media--inline-images (search-start search-end)
"Find all `Media_Links:' in the range from SEARCH-START to SEARCH-END.
Replace them with the referenced image."
(save-excursion
(goto-char search-start)
(let (line-details)
(while (setq line-details (mastodon-media--select-next-media-line
search-end))
(let* ((start (car line-details))
(end (cadr line-details))
(media-type (cadr (cdr line-details)))
(image-url (get-text-property start 'media-url)))
(if (not (mastodon-media--valid-link-p image-url))
;; mark it at least as not needing loading any more
(put-text-property start end 'media-state 'invalid-url)
;; proceed to load this image asynchronously
(put-text-property start end 'media-state 'loading)
(mastodon-media--load-image-from-url
image-url media-type start (- end start))))))))
;; (mastodon-media--moving-image-overlay start end)))))))
;; (defun mastodon-media--moving-image-overlay (start end)
;; "Add play symbol overlay to moving image media items."
;; (let ((ov (make-overlay start end))
;; (type (get-text-property start 'mastodon-media-type)))
;; (when (or (equal type "gifv")
;; (equal type "video"))
;; (overlay-put
;; ov
;; 'after-string
;; (propertize " "
;; 'face
;; '((:height 1.5 :inherit 'font-lock-comment-face)))))))
(defun mastodon-media--get-avatar-rendering (avatar-url)
"Return the string to be written that renders the avatar at AVATAR-URL."
;; We use just an empty space as the textual representation.
;; This is what a user will see on a non-graphical display
;; where not showing an avatar at all is preferable.
(let ((image-options (when (or (image-type-available-p 'imagemagick)
(image-transforms-p)) ; inbuilt scaling in 27.1
`(:height ,mastodon-media--avatar-height))))
(concat
(propertize " "
'media-url avatar-url
'media-state 'needs-loading
'media-type 'avatar
'display (apply #'create-image mastodon-media--generic-avatar-data
(if (version< emacs-version "27.1")
(when image-options 'imagemagick)
nil) ; inbuilt scaling in 27.1
t image-options))
" ")))
(defun mastodon-media--get-media-link-rendering (media-url &optional full-remote-url
type caption)
"Return the string to be written that renders the image at MEDIA-URL.
FULL-REMOTE-URL is used for `shr-browse-image'.
TYPE is the attachment's type field on the server.
CAPTION is the image caption if provided."
(let* ((help-echo-base "RET/i: load full image (prefix: copy URL), +/-: zoom, r: rotate, o: save preview")
(help-echo (if caption
(concat help-echo-base
"\n\""
caption "\"")
help-echo-base)))
(concat
(mastodon-tl--propertize-img-str-or-url
"[img]" media-url full-remote-url type help-echo
(create-image mastodon-media--generic-broken-image-data nil t))
" ")))
(provide 'mastodon-media)
;;; mastodon-media.el ends here
;;; mastodon-iso.el --- ISO language code lists for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2022 Marty Hiatt
;; Author: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;;; Code:
;; via
;; https://github.com/VyrCossont/mastodon/blob/0836f4a656d5486784cadfd7d0cd717bb67ede4c/app/helpers/languages_helper.rb
;; and
;; https://github.com/Shinmera/language-codes/blob/master/data/iso-639-3.lisp
(defvar mastodon-iso-639-1
'(("Abkhazian" . "ab")
("Afar" . "aa")
("Afrikaans" . "af")
("Akan" . "ak")
("Albanian" . "sq")
("Amharic" . "am")
("Arabic" . "ar")
("Aragonese" . "an")
("Armenian" . "hy")
("Assamese" . "as")
("Avaric" . "av")
("Avestan" . "ae")
("Aymara" . "ay")
("Azerbaijani" . "az")
("Bambara" . "bm")
("Bashkir" . "ba")
("Basque" . "eu")
("Belarusian" . "be")
("Bengali" . "bn")
("Bihari languages" . "bh")
("Bislama" . "bi")
("Bosnian" . "bs")
("Breton" . "br")
("Bulgarian" . "bg")
("Burmese" . "my")
("Central Khmer" . "km")
("Chamorro" . "ch")
("Chechen" . "ce")
("Chinese" . "zh")
("Chuvash" . "cv")
("Cornish" . "kw")
("Corsican" . "co")
("Cree" . "cr")
("Croatian" . "hr")
("Czech" . "cs")
("Danish" . "da")
("Dzongkha" . "dz")
("English" . "en")
("Esperanto" . "eo")
("Estonian" . "et")
("Ewe" . "ee")
("Faroese" . "fo")
("Fijian" . "fj")
("Finnish" . "fi")
("Dutch" . "nl")
("French" . "fr")
("Fulah" . "ff")
("Galician" . "gl")
("Ganda" . "lg")
("Georgian" . "ka")
("German" . "de")
("Greek" . "el")
("Guarani" . "gn")
("Gujarati" . "gu")
("Haitian" . "ht")
("Hausa" . "ha")
("Hebrew" . "he")
("Herero" . "hz")
("Hindi" . "hi")
("Hiri Motu" . "ho")
("Hungarian" . "hu")
("Icelandic" . "is")
("Ido" . "io")
("Igbo" . "ig")
("Indonesian" . "id")
("Interlingua" . "ia")
("Inuktitut" . "iu")
("Inupiaq" . "ik")
("Irish" . "ga")
("Italian" . "it")
("Japanese" . "ja")
("Japanese" . "jp")
("Javanese" . "jv")
("Kalaallisut" . "kl")
("Kannada" . "kn")
("Kanuri" . "kr")
("Kashmiri" . "ks")
("Kazakh" . "kk")
("Kikuyu" . "ki")
("Kinyarwanda" . "rw")
("Komi" . "kv")
("Kongo" . "kg")
("Korean" . "ko")
("Kurdish" . "ku")
("Kuanyama" . "kj")
("Kirghiz" . "ky")
("Lao" . "lo")
("Latin" . "la")
("Latvian" . "lv")
("Limburgan" . "li")
("Lingala" . "ln")
("Lithuanian" . "lt")
("Luba-Katanga" . "lu")
("Luxembourgish" . "lb")
("Macedonian" . "mk")
("Malagasy" . "mg")
("Malay" . "ms")
("Malayalam" . "ml")
("Divehi" . "dv")
("Maltese" . "mt")
("Manx" . "gv")
("Maori" . "mi")
("Marathi" . "mr")
("Marshallese" . "mh")
("Mongolian" . "mn")
("Nauru" . "na")
("Navajo" . "nv")
("Ndonga" . "ng")
("Nepali" . "ne")
("Ndebele, North" . "nd")
("Northern Sami" . "se")
("Norwegian" . "no")
("Bokmål, Norwegian" . "nb")
("Chichewa" . "ny")
("Norwegian Nynorsk" . "nn")
("Interlingue" . "ie")
("Occitan" . "oc")
("Ojibwa" . "oj")
("Church Slavic" . "cu")
("Oriya" . "or")
("Oromo" . "om")
("Ossetian" . "os")
("Pali" . "pi")
("Persian" . "fa")
("Polish" . "pl")
("Portuguese" . "pt")
("Panjabi" . "pa")
("Pushto" . "ps")
("Quechua" . "qu")
("Romanian" . "ro")
("Romansh" . "rm")
("Rundi" . "rn")
("Russian" . "ru")
("Samoan" . "sm")
("Sango" . "sg")
("Sanskrit" . "sa")
("Sardinian" . "sc")
("Gaelic" . "gd")
("Serbian" . "sr")
("Shona" . "sn")
("Sichuan Yi" . "ii")
("Sindhi" . "sd")
("Sinhala" . "si")
("Slovak" . "sk")
("Slovenian" . "sl")
("Somali" . "so")
("Sotho, Southern" . "st")
("Ndebele, South" . "nr")
("Spanish" . "es")
("Sundanese" . "su")
("Swahili" . "sw")
("Swati" . "ss")
("Swedish" . "sv")
("Tagalog" . "tl")
("Tahitian" . "ty")
("Tajik" . "tg")
("Tamil" . "ta")
("Tatar" . "tt")
("Telugu" . "te")
("Thai" . "th")
("Tibetan" . "bo")
("Tigrinya" . "ti")
("Tonga (Tonga Islands)" . "to")
("Tsonga" . "ts")
("Tswana" . "tn")
("Turkish" . "tr")
("Turkmen" . "tk")
("Twi" . "tw")
("Ukrainian" . "uk")
("Urdu" . "ur")
("Uighur" . "ug")
("Uzbek" . "uz")
("Catalan" . "ca")
("Venda" . "ve")
("Vietnamese" . "vi")
("Volapük" . "vo")
("Walloon" . "wa")
("Welsh" . "cy")
("Western Frisian" . "fy")
("Wolof" . "wo")
("Xhosa" . "xh")
("Yiddish" . "yi")
("Yoruba" . "yo")
("Zhuang" . "za")
("Zulu" . "zu")))
;; web UI doesn't respect these for now
(defvar mastodon-iso-639-regional
'(("es-AR" "Español (Argentina)")
("es-MX" "Español (México)")
("pt-BR" "Português (Brasil)")
("pt-PT" "Português (Portugal)")
("sr-Latn" "Srpski (latinica)")
("zh-CN" "简体中文")
("zh-HK" "繁體中文(香港)")
("zh-TW" "繁體中文(臺灣)")))
(defvar mastodon-iso-639-3
'(("ast" "Asturian" "Asturianu")
("ckb" "Sorani (Kurdish)" "سۆرانی")
("jbo" "Lojban" "la .lojban.")
("kab" "Kabyle" "Taqbaylit")
("kmr" "Kurmanji (Kurdish)" "Kurmancî")
("ldn" "Láadan" "Láadan")
("lfn" "Lingua Franca Nova" "lingua franca nova")
("tok" "Toki Pona" "toki pona")
("zba" "Balaibalan" "باليبلن")
("zgh" "Standard Moroccan Tamazight" "ⵜⴰⵎⴰⵣⵉⵖⵜ")))
(provide 'mastodon-iso)
;;; mastodon-iso.el ends here
;;; mastodon-inspect.el --- Client for Mastodon -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Package-Requires: ((emacs "27.1"))
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; Some tools to help inspect / debug mastodon.el
;;; Code:
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--get-json "mastodon-http")
(autoload 'mastodon-http--get-search-json "mastodon-http")
(autoload 'mastodon-media--inline-images "mastodon-media")
(autoload 'mastodon-mode "mastodon")
(autoload 'mastodon-tl--as-string "mastodon-tl")
(autoload 'mastodon-tl--property "mastodon-tl")
(autoload 'mastodon-tl--toot "mastodon-tl")
(defvar mastodon-instance-url)
(defgroup mastodon-inspect nil
"Tools to help inspect toots."
:prefix "mastodon-inspect-"
:group 'external)
(defun mastodon-inspect--dump-json-in-buffer (name json)
"Buffer NAME is opened and JSON in printed into it."
(switch-to-buffer-other-window name)
(erase-buffer)
(let ((print-level nil)
(print-length nil))
(insert (pp json t)))
(goto-char (point-min))
(emacs-lisp-mode)
(message "success"))
(defun mastodon-inspect--toot ()
"Find next toot and dump its meta data into new buffer."
(interactive)
(mastodon-inspect--dump-json-in-buffer
(concat "*mastodon-inspect-toot-"
(mastodon-tl--as-string (mastodon-tl--property 'toot-id))
"*")
(mastodon-tl--property 'toot-json)))
(defun mastodon-inspect--download-single-toot (toot-id)
"Download the toot/status represented by TOOT-ID."
(mastodon-http--get-json
(mastodon-http--api (concat "statuses/" toot-id))))
(defun mastodon-inspect--view-single-toot (toot-id)
"View the toot/status represented by TOOT-ID."
(interactive "s Toot ID: ")
(let ((buffer (get-buffer-create (concat "*mastodon-status-" toot-id "*"))))
(with-current-buffer buffer
(let ((toot (mastodon-inspect--download-single-toot toot-id )))
(mastodon-tl--toot toot)
(goto-char (point-min))
(while (search-forward "\n\n\n | " nil t)
(replace-match "\n | "))
(mastodon-media--inline-images (point-min) (point-max))))
(switch-to-buffer-other-window buffer)
(mastodon-mode)))
(defun mastodon-inspect--view-single-toot-source (toot-id)
"View the ess source of a toot/status represented by TOOT-ID."
(interactive "s Toot ID: ")
(mastodon-inspect--dump-json-in-buffer
(concat "*mastodon-status-raw-" toot-id "*")
(mastodon-inspect--download-single-toot toot-id)))
(defvar mastodon-inspect--search-query-accounts-result)
(defvar mastodon-inspect--single-account-json)
(defvar mastodon-inspect--search-query-full-result)
(defvar mastodon-inspect--search-result-tags)
(defun mastodon-inspect--get-search-result (query)
"Inspect function for a search result for QUERY."
(interactive)
(setq mastodon-inspect--search-query-full-result
(append ; convert vector to list
(mastodon-http--get-search-json
(format "%s/api/v2/search" mastodon-instance-url)
query)
nil))
(setq mastodon-inspect--search-result-tags
(append (cdr
(caddr mastodon-inspect--search-query-full-result))
nil)))
(defun mastodon-inspect--get-search-account (query)
"Return JSON for a single account after search QUERY."
(interactive)
(setq mastodon-inspect--search-query-accounts-result
(append ; convert vector to list
(mastodon-http--get-search-json
(format "%s/api/v1/accounts/search" mastodon-instance-url)
query)
nil))
(setq mastodon-inspect--single-account-json
(car mastodon-inspect--search-query-accounts-result)))
(provide 'mastodon-inspect)
;;; mastodon-inspect.el ends here
;;; mastodon-http.el --- HTTP request/response functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-http.el provides HTTP request/response functions.
;;; Code:
(require 'json)
(require 'request) ; for attachments upload
(require 'url)
(defvar mastodon-instance-url)
(defvar mastodon-toot--media-attachment-ids)
(defvar mastodon-toot--media-attachment-filenames)
(autoload 'mastodon-auth--access-token "mastodon-auth")
(autoload 'mastodon-toot--update-status-fields "mastodon-toot")
(defvar mastodon-http--api-version "v1")
(defconst mastodon-http--timeout 15
"HTTP request timeout, in seconds. Has no effect on Emacs < 26.1.")
(defun mastodon-http--api (endpoint)
"Return Mastodon API URL for ENDPOINT."
(concat mastodon-instance-url "/api/"
mastodon-http--api-version "/" endpoint))
(defun mastodon-http--response ()
"Capture response buffer content as string."
(with-current-buffer (current-buffer)
(buffer-substring-no-properties (point-min) (point-max))))
(defun mastodon-http--response-body (pattern)
"Return substring matching PATTERN from `mastodon-http--response'."
(let ((resp (mastodon-http--response)))
(string-match pattern resp)
(match-string 0 resp)))
(defun mastodon-http--status ()
"Return HTTP Response Status Code from `mastodon-http--response'."
(let* ((status-line (mastodon-http--response-body "^HTTP/1.*$")))
(string-match "[0-9][0-9][0-9]" status-line)
(match-string 0 status-line)))
(defun mastodon-http--url-retrieve-synchronously (url &optional silent)
"Retrieve URL asynchronously.
This is a thin abstraction over the system
`url-retrieve-synchronously'. Depending on which version of this
is available we will call it with or without a timeout.
SILENT means don't message."
(if (< (cdr (func-arity 'url-retrieve-synchronously)) 4)
(url-retrieve-synchronously url)
(url-retrieve-synchronously url (or silent nil) nil mastodon-http--timeout)))
(defun mastodon-http--triage (response success)
"Determine if RESPONSE was successful. Call SUCCESS if successful.
Message status and JSON error from RESPONSE if unsuccessful."
(let ((status (with-current-buffer response
(mastodon-http--status))))
(if (string-prefix-p "2" status)
(funcall success)
;; don't switch to buffer, just with-current-buffer the response:
;; (switch-to-buffer response)
;; 404 sometimes returns http response so --process-json fails:
(if (string-prefix-p "404" status)
(message "Error %s: page not found" status)
(let ((json-response (with-current-buffer response
(mastodon-http--process-json))))
(message "Error %s: %s" status (alist-get 'error json-response)))))))
(defun mastodon-http--read-file-as-string (filename)
"Read a file FILENAME as a string. Used to generate image preview."
(with-temp-buffer
(insert-file-contents filename)
(string-to-unibyte (buffer-string))))
(defmacro mastodon-http--authorized-request (method body &optional unauthenticated-p)
"Make a METHOD type request using BODY, with Mastodon authorization.
Unless UNAUTHENTICATED-P is non-nil."
(declare (debug 'body))
`(let ((url-request-method ,method)
(url-request-extra-headers
(unless ,unauthenticated-p
(list (cons "Authorization"
(concat "Bearer " (mastodon-auth--access-token)))))))
,body))
(defun mastodon-http--build-params-string (params)
"Build a request parameters string from parameters alist PARAMS."
;; (url-build-query-string args nil))
;; url-build-query-string adds 'nil' to empty params so lets stay with our
;; own:
(mapconcat (lambda (p)
(concat (url-hexify-string (car p))
"="
(url-hexify-string (cdr p))))
params
"&"))
(defun mastodon-http--build-array-params-alist (param-str array)
"Return parameters alist using PARAM-STR and ARRAY param values.
Used for API form data parameters that take an array."
(cl-loop for x in array
collect (cons param-str x)))
(defun mastodon-http--post (url &optional params headers unauthenticated-p)
"POST synchronously to URL, optionally with PARAMS and HEADERS.
Authorization header is included by default unless UNAUTHENTICATED-P is non-nil."
(mastodon-http--authorized-request
"POST"
(let ((url-request-data
(when params
(mastodon-http--build-params-string params)))
(url-request-extra-headers
(append url-request-extra-headers ; auth set in macro
;; pleroma compat:
(unless (assoc "Content-Type" headers)
'(("Content-Type" . "application/x-www-form-urlencoded")))
headers)))
(with-temp-buffer
(mastodon-http--url-retrieve-synchronously url)))
unauthenticated-p))
(defun mastodon-http--get (url &optional params silent)
"Make synchronous GET request to URL.
PARAMS is an alist of any extra parameters to send with the request.
SILENT means don't message."
(mastodon-http--authorized-request
"GET"
;; url-request-data doesn't seem to work with GET requests:
(let ((url (if params
(concat url "?"
(mastodon-http--build-params-string params))
url)))
(mastodon-http--url-retrieve-synchronously url silent))))
(defun mastodon-http--get-response (url &optional params no-headers silent vector)
"Make synchronous GET request to URL. Return JSON and response headers.
PARAMS is an alist of any extra parameters to send with the request.
SILENT means don't message.
NO-HEADERS means don't collect http response headers.
VECTOR means return json arrays as vectors."
(with-current-buffer (mastodon-http--get url params silent)
(mastodon-http--process-response no-headers vector)))
(defun mastodon-http--get-json (url &optional params silent vector)
"Return only JSON data from URL request.
PARAMS is an alist of any extra parameters to send with the request.
SILENT means don't message.
VECTOR means return json arrays as vectors."
(car (mastodon-http--get-response url params :no-headers silent vector)))
(defun mastodon-http--process-json ()
"Return only JSON data from async URL request.
Callback to `mastodon-http--get-json-async', usually
`mastodon-tl--init*', is run on the result."
(car (mastodon-http--process-response :no-headers)))
(defun mastodon-http--process-response (&optional no-headers vector)
"Process http response.
Return a cons of JSON list and http response headers.
If NO-HEADERS is non-nil, just return the JSON.
VECTOR means return json arrays as vectors.
Callback to `mastodon-http--get-response-async', usually
`mastodon-tl--init*', is run on the result."
;; view raw response:
;; (switch-to-buffer (current-buffer))
(let ((headers (unless no-headers
(mastodon-http--process-headers))))
(goto-char (point-min))
(re-search-forward "^$" nil 'move)
(let ((json-array-type (if vector 'vector 'list))
(json-string
(decode-coding-string
(buffer-substring-no-properties (point) (point-max))
'utf-8)))
(kill-buffer)
;; (unless (or (string-empty-p json-string) (null json-string))
(cond ((or (string-empty-p json-string) (null json-string))
nil)
;; if we don't have json, maybe we have a plain string error
;; message (misskey works like this for instance, but there are
;; probably less dunce ways to do this):
;; FIXME: friendica at least sends plain html if endpoint not found.
((not (or (string-prefix-p "\n{" json-string)
(string-prefix-p "\n[" json-string)))
(error "%s" json-string))
(t
`(,(json-read-from-string json-string) . ,headers))))))
(defun mastodon-http--process-headers ()
"Return an alist of http response headers."
(switch-to-buffer (current-buffer))
(goto-char (point-min))
(let* ((head-str (buffer-substring-no-properties
(point-min)
(re-search-forward "^$" nil 'move)))
(head-list (split-string head-str "\n")))
(mapcar (lambda (x)
(let ((list (split-string x ": ")))
(cons (car list) (cadr list))))
head-list)))
(defun mastodon-http--delete (url &optional params)
"Make DELETE request to URL.
PARAMS is an alist of any extra parameters to send with the request."
;; url-request-data only works with POST requests?
(let ((url
(if params
(concat url "?"
(mastodon-http--build-params-string params))
url)))
(mastodon-http--authorized-request
"DELETE"
(with-temp-buffer
(mastodon-http--url-retrieve-synchronously url)))))
(defun mastodon-http--put (url &optional params headers)
"Make PUT request to URL.
PARAMS is an alist of any extra parameters to send with the request.
HEADERS is an alist of any extra headers to send with the request."
(mastodon-http--authorized-request
"PUT"
(let ((url-request-data
(when params (mastodon-http--build-params-string params)))
(url-request-extra-headers
(append url-request-extra-headers ; auth set in macro
;; pleroma compat:
(unless (assoc "Content-Type" headers)
'(("Content-Type" . "application/x-www-form-urlencoded")))
headers)))
(with-temp-buffer (mastodon-http--url-retrieve-synchronously url)))))
(defun mastodon-http--append-query-string (url params)
"Append PARAMS to URL as query strings and return it.
PARAMS should be an alist as required by `url-build-query-string'."
(let ((query-string (url-build-query-string params)))
(concat url "?" query-string)))
;; profile update functions
(defun mastodon-http--patch-json (url &optional params)
"Make synchronous PATCH request to URL. Return JSON response.
Optionally specify the PARAMS to send."
(with-current-buffer (mastodon-http--patch url params)
(mastodon-http--process-json)))
(defun mastodon-http--patch (base-url &optional params)
"Make synchronous PATCH request to BASE-URL.
Optionally specify the PARAMS to send."
(mastodon-http--authorized-request
"PATCH"
(let ((url
(concat base-url "?"
(mastodon-http--build-params-string params))))
(mastodon-http--url-retrieve-synchronously url))))
;; Asynchronous functions
(defun mastodon-http--get-async (url &optional params callback &rest cbargs)
"Make GET request to URL.
Pass response buffer to CALLBACK function with args CBARGS.
PARAMS is an alist of any extra parameters to send with the request."
(let ((url (if params
(concat url "?"
(mastodon-http--build-params-string params))
url)))
(mastodon-http--authorized-request
"GET"
(url-retrieve url callback cbargs))))
(defun mastodon-http--get-response-async (url &optional params callback &rest cbargs)
"Make GET request to URL. Call CALLBACK with http response and CBARGS.
PARAMS is an alist of any extra parameters to send with the request."
(mastodon-http--get-async
url
params
(lambda (status)
(when status ;; only when we actually get sth?
(apply callback (mastodon-http--process-response) cbargs)))))
(defun mastodon-http--get-json-async (url &optional params callback &rest cbargs)
"Make GET request to URL. Call CALLBACK with json-list and CBARGS.
PARAMS is an alist of any extra parameters to send with the request."
(mastodon-http--get-async
url
params
(lambda (status)
(when status ;; only when we actually get sth?
(apply callback (mastodon-http--process-json) cbargs)))))
(defun mastodon-http--post-async (url params _headers &optional callback &rest cbargs)
"POST asynchronously to URL with PARAMS and HEADERS.
Then run function CALLBACK with arguements CBARGS.
Authorization header is included by default unless UNAUTHENTICED-P is non-nil."
(mastodon-http--authorized-request
"POST"
(let ((request-timeout 5)
(url-request-data
(when params
(mastodon-http--build-params-string params))))
(with-temp-buffer
(url-retrieve url callback cbargs)))))
;; TODO: test for curl first?
(defun mastodon-http--post-media-attachment (url filename caption)
"Make POST request to upload FILENAME with CAPTION to the server's media URL.
The upload is asynchronous. On succeeding,
`mastodon-toot--media-attachment-ids' is set to the id(s) of the
item uploaded, and `mastodon-toot--update-status-fields' is run."
(let* ((file (file-name-nondirectory filename))
(request-backend 'curl))
(request
url
:type "POST"
:params `(("description" . ,caption))
:files `(("file" . (,file :file ,filename
:mime-type "multipart/form-data")))
:parser 'json-read
:headers `(("Authorization" . ,(concat "Bearer "
(mastodon-auth--access-token))))
:sync nil
:success (cl-function
(lambda (&key data &allow-other-keys)
(when data
(push (alist-get 'id data)
mastodon-toot--media-attachment-ids) ; add ID to list
(message "%s file %s with id %S and caption '%s' uploaded!"
(capitalize (alist-get 'type data))
file
(alist-get 'id data)
(alist-get 'description data))
(mastodon-toot--update-status-fields))))
:error (cl-function
(lambda (&key error-thrown &allow-other-keys)
(cond
;; handle curl errors first (eg 26, can't read file/path)
;; because the '=' test below fails for them
;; they have the form (error . error message 24)
((not (proper-list-p error-thrown)) ; not dotted list
(message "Got error: %s. Shit went south." (cdr error-thrown)))
;; handle mastodon api errors
;; they have the form (error http 401)
((= (car (last error-thrown)) 401)
(message "Got error: %s Unauthorized: The access token is invalid" error-thrown))
((= (car (last error-thrown)) 422)
(message "Got error: %s Unprocessable entity: file or file type is unsupported or invalid" error-thrown))
(t
(message "Got error: %s Shit went south"
error-thrown))))))))
(provide 'mastodon-http)
;;; mastodon-http.el ends here
;;; mastodon-discover.el --- Use Mastodon.el with discover.el -*- lexical-binding: t -*-
;; Copyright (C) 2019 Johnson Denen
;; Copyright (C) 2020-2022 Marty Hiatt
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Marty Hiatt <martianhiatus@riseup.net>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; This adds optional functionality that can be used if the dicover package
;; is present.
;;
;; See the README file for how to use this.
;;; Code:
(declare-function discover-add-context-menu "discover")
(defun mastodon-discover ()
"Plug Mastodon functionality into `discover'."
(interactive)
(when (require 'discover nil :noerror)
(discover-add-context-menu
:bind "?"
:mode 'mastodon-mode
:mode-hook 'mastodon-mode-hook
:context-menu
'(mastodon
(description "Mastodon feed viewer")
(actions
("Toots"
("A" "View profile of author" mastodon-profile--get-toot-author)
("b" "Boost" mastodon-toot--boost)
("f" "Favourite" mastodon-toot--favourite)
("c" "Toggle hidden text (CW)" mastodon-tl--toggle-spoiler-text-in-toot)
("k" "Bookmark toot" mastodon-toot--toggle-bookmark)
("v" "Vote on poll" mastodon-tl--poll-vote)
("n" "Next" mastodon-tl--goto-next-toot)
("p" "Prev" mastodon-tl--goto-prev-toot)
("TAB" "Next link item" mastodon-tl--next-tab-item)
("S-TAB" "Prev link item" mastodon-tl--previous-tab-item)
;; NB: (when (require 'mpv etc. calls don't work here
("C-RET" "Play media" mastodon-tl--mpv-play-video-at-point)
("t" "New toot" mastodon-toot)
("r" "Reply" mastodon-toot--reply)
("C" "Copy toot URL" mastodon-toot--copy-toot-url)
("d" "Delete (your) toot" mastodon-toot--delete-toot)
("D" "Delete and redraft (your) toot" mastodon-toot--delete-toot)
("e" "Edit (your) toot" mastodon-toot--edit-toot-at-point)
("E" "View edits of (your) toot" mastodon-toot--view-toot-edits)
("i" "Pin/Unpin (your) toot" mastodon-toot--pin-toot-toggle)
("P" "View user profile" mastodon-profile--show-user)
("a" "Translate toot at point" mastodon-toot--translate-toot-text)
("T" "View thread" mastodon-tl--thread)
("v" "Vote on poll" mastodon-tl--poll-vote)
("," "View toot's favouriters" mastodon-toot--list-toot-favouriters)
("." "View toot's boosters" mastodon-toot--list-toot-boosters)
("/" "Switch buffers" mastodon-switch-to-buffer))
("Views"
("h/?" "View mode help/keybindings" describe-mode)
("#" "Tag search" mastodon-tl--get-tag-timeline)
(":" "List followed tags" mastodon-tl--list-followed-tags)
("F" "Federated" mastodon-tl--get-federated-timeline)
("H" "Home" mastodon-tl--get-home-timeline)
("L" "Local" mastodon-tl--get-local-timeline)
("N" "Notifications" mastodon-notifications-get)
("@" "Notifications with mentions" mastodon-notifications--get-mentions)
("g/u" "Update timeline" mastodon-tl--update)
("s" "Search" mastodon-search--search-query)
("O" "Jump to your profile" mastodon-profile--my-profile)
("U" "Update your profile note" mastodon-profile--update-user-profile-note)
("K" "View bookmarks" mastodon-profile--view-bookmarks)
("V" "View favourites" mastodon-profile--view-favourites)
("R" "View follow requests" mastodon-profile--view-follow-requests)
("G" "View follow suggestions" mastodon-tl--get-follow-suggestions)
("I" "View filters" mastodon-tl--view-filters)
("X" "View lists" mastodon-tl--view-lists)
("S" "View scheduled toots" mastodon-tl--view-scheduled-toots)
(";" "View instance description" mastodon-tl--view-instance-description))
("Users"
("W" "Follow" mastodon-tl--follow-user)
("C-S-W" "Unfollow" mastodon-tl--unfollow-user)
("M" "Mute" mastodon-tl--mute-user)
("C-S-M" "Unmute" mastodon-tl--unmute-user)
("B" "Block" mastodon-tl--block-user)
("C-S-B" "Unblock" mastodon-tl--unblock-user))
("Images"
;; RET errors here also :/
("<return>/i" "Load full image in browser" 'shr-browse-image)
("r" "rotate" 'image-rotate)
("+" "zoom in" 'image-increase-size)
("-" "zoom out" 'image-decrease-size)
("u" "copy URL" 'shr-maybe-probe-and-copy-url))
("Profile view"
("C-c C-c" "Cycle profile views" mastodon-profile--account-view-cycle))
("Quit"
("q" "Quit mastodon and bury buffer." kill-this-buffer)
("Q" "Quit mastodon buffer and kill window." kill-buffer-and-window)
("M-C-q" "Quit mastodon and kill all buffers." mastodon-kill-all-buffers)))))))
(provide 'mastodon-discover)
;;; mastodon-discover.el ends here
;;; mastodon-client.el --- Client functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2021 Abhiseck Paira <abhiseckpaira@disroot.org>
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-client.el supports registering the Emacs client with your Mastodon instance.
;;; Code:
(require 'plstore)
(require 'json)
(require 'url)
(defvar mastodon-instance-url)
(defvar mastodon-active-user)
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--post "mastodon-http")
(defcustom mastodon-client--token-file (concat user-emacs-directory "mastodon.plstore")
"File path where Mastodon access tokens are stored."
:group 'mastodon
:type 'file)
(defvar mastodon-client--client-details-alist nil
"An alist of Client id and secrets keyed by the instance url.")
(defvar mastodon-client--active-user-details-plist nil
"A plist of active user details.")
(defvar mastodon-client-scopes "read write follow"
"Scopes to pass to oauth during registration.")
(defvar mastodon-client-website "https://codeberg.org/martianh/mastodon.el"
"Website of mastodon.el.")
(defvar mastodon-client-redirect-uri "urn:ietf:wg:oauth:2.0:oob"
"Redirect_uri as required by oauth.")
(defun mastodon-client--register ()
"POST client to Mastodon."
(mastodon-http--post
(mastodon-http--api "apps")
`(("client_name" . "mastodon.el")
("redirect_uris" . ,mastodon-client-redirect-uri)
("scopes" . ,mastodon-client-scopes)
("website" . ,mastodon-client-website))
nil
:unauthenticated))
(defun mastodon-client--fetch ()
"Return JSON from `mastodon-client--register' call."
(with-current-buffer (mastodon-client--register)
(goto-char (point-min))
(re-search-forward "^$" nil 'move)
(let ((json-object-type 'plist)
(json-key-type 'keyword)
(json-array-type 'vector)
(json-string (buffer-substring-no-properties (point) (point-max))))
(json-read-from-string json-string))))
(defun mastodon-client--token-file ()
"Return `mastodon-client--token-file'."
mastodon-client--token-file)
(defun mastodon-client--store ()
"Store client_id and client_secret in `mastodon-client--token-file'.
Make `mastodon-client--fetch' call to determine client values."
(let ((plstore (plstore-open (mastodon-client--token-file)))
(client (mastodon-client--fetch))
;; alexgriffith reported seeing ellipses in the saved output
;; which indicate some output truncating. Nothing in `plstore-save'
;; seems to ensure this cannot happen so let's do that ourselves:
(print-length nil)
(print-level nil))
(plstore-put plstore (concat "mastodon-" mastodon-instance-url) client nil)
(plstore-save plstore)
(plstore-close plstore)
client))
(defun mastodon-client--remove-key-from-plstore (plstore)
"Remove KEY from PLSTORE."
(cdr plstore))
;; Actually it returns a plist with client-details if such details are
;; already stored in mastodon.plstore
(defun mastodon-client--read ()
"Retrieve client_id and client_secret from `mastodon-client--token-file'."
(let* ((plstore (plstore-open (mastodon-client--token-file)))
(mastodon (plstore-get plstore (concat "mastodon-" mastodon-instance-url))))
(mastodon-client--remove-key-from-plstore mastodon)))
(defun mastodon-client--general-read (key)
"Retrieve the plstore item keyed by KEY.
Return plist without the KEY."
(let* ((plstore (plstore-open (mastodon-client--token-file)))
(plstore-item (plstore-get plstore key)))
(mastodon-client--remove-key-from-plstore plstore-item)))
(defun mastodon-client--make-user-details-plist ()
"Make a plist with current user details. Return it."
`(:username ,(mastodon-client--form-user-from-vars)
:instance ,mastodon-instance-url
:client_id ,(plist-get (mastodon-client) :client_id)
:client_secret ,(plist-get (mastodon-client) :client_secret)))
(defun mastodon-client--store-access-token (token)
"Save TOKEN as :access_token in plstore of the current user.
Return the plist after the operation."
(let* ((user-details (mastodon-client--make-user-details-plist))
(plstore (plstore-open (mastodon-client--token-file)))
(username (plist-get user-details :username))
(plstore-value (setq user-details
(plist-put user-details :access_token token)))
(print-length nil)
(print-level nil))
(plstore-put plstore (concat "user-" username) plstore-value nil)
(plstore-save plstore)
(plstore-close plstore)
plstore-value))
(defun mastodon-client--make-user-active (user-details)
"USER-DETAILS is a plist consisting of user details."
(let ((plstore (plstore-open (mastodon-client--token-file)))
(print-length nil)
(print-level nil))
(plstore-put plstore "active-user" user-details nil)
(plstore-save plstore)
(plstore-close plstore)))
(defun mastodon-client--form-user-from-vars ()
"Create a username from user variable. Return that username.
Username in the form user@instance.com is formed from the
variables `mastodon-instance-url' and `mastodon-active-user'."
(concat mastodon-active-user
"@"
(url-host (url-generic-parse-url mastodon-instance-url))))
(defun mastodon-client--make-current-user-active ()
"Make the user specified by user variables active user.
Return the details (plist)."
(let ((username (mastodon-client--form-user-from-vars))
user-plist)
(when (setq user-plist
(mastodon-client--general-read (concat "user-" username)))
(mastodon-client--make-user-active user-plist))
user-plist))
(defun mastodon-client--current-user-active-p ()
"Return user-details if the current user is active.
Otherwise return nil."
(let ((username (mastodon-client--form-user-from-vars))
(user-details (mastodon-client--general-read "active-user")))
(when (and user-details
(equal (plist-get user-details :username) username))
user-details)))
(defun mastodon-client--active-user ()
"Return the details of the currently active user.
Details is a plist."
(let ((active-user-details mastodon-client--active-user-details-plist))
(unless active-user-details
(setq active-user-details
(or (mastodon-client--current-user-active-p)
(mastodon-client--make-current-user-active)))
(setq mastodon-client--active-user-details-plist
active-user-details))
active-user-details))
(defun mastodon-client ()
"Return variable client secrets to use for `mastodon-instance-url'.
Read plist from `mastodon-client--token-file' if variable is nil.
Fetch and store plist if `mastodon-client--read' returns nil."
(let ((client-details
(cdr (assoc mastodon-instance-url mastodon-client--client-details-alist))))
(unless client-details
(setq client-details
(or (mastodon-client--read)
(mastodon-client--store)))
(push (cons mastodon-instance-url client-details)
mastodon-client--client-details-alist))
client-details))
(provide 'mastodon-client)
;;; mastodon-client.el ends here
;;; mastodon-autoloads.el --- automatically extracted autoloads -*- lexical-binding: t -*-
;;
;;; Code:
(add-to-list 'load-path (directory-file-name
(or (file-name-directory #$) (car load-path))))
;;;### (autoloads nil "mastodon" "mastodon.el" (0 0 0 0))
;;; Generated autoloads from mastodon.el
(autoload 'mastodon "mastodon" "\
Connect Mastodon client to `mastodon-instance-url' instance." t nil)
(autoload 'mastodon-toot "mastodon" "\
Update instance with new toot. Content is captured in a new buffer.
If USER is non-nil, insert after @ symbol to begin new toot.
If REPLY-TO-ID is non-nil, attach new toot to a conversation.
If REPLY-JSON is the json of the toot being replied to.
\(fn &optional USER REPLY-TO-ID REPLY-JSON)" t nil)
(autoload 'mastodon-notifications-get "mastodon" "\
Display NOTIFICATIONS in buffer.
Optionally only print notifications of type TYPE, a string.
BUFFER-NAME is added to \"*mastodon-\" to create the buffer name.
FORCE means do not try to update an existing buffer, but fetch
from the server and load anew.
\(fn &optional TYPE BUFFER-NAME FORCE)" t nil)
(autoload 'mastodon-url-lookup "mastodon" "\
If a URL resembles a mastodon link, try to load in `mastodon.el'.
Does a WebFinger lookup.
URL can be arg QUERY-URL, or URL at point, or provided by the user.
If a status or account is found, load it in `mastodon.el', if
not, just browse the URL in the normal fashion.
\(fn &optional QUERY-URL)" t nil)
(add-hook 'mastodon-mode-hook (lambda nil (when (require 'emojify nil :noerror) (emojify-mode t) (when mastodon-toot--enable-custom-instance-emoji (mastodon-toot--enable-custom-emoji)))))
(add-hook 'mastodon-mode-hook #'mastodon-profile--fetch-server-account-settings)
(register-definition-prefixes "mastodon" '("mastodon-"))
;;;***
;;;### (autoloads nil "mastodon-async" "mastodon-async.el" (0 0 0
;;;;;; 0))
;;; Generated autoloads from mastodon-async.el
(autoload 'mastodon-async-mode "mastodon-async" "\
Async Mastodon.
This is a minor mode. If called interactively, toggle the
`Mastodon-Async mode' mode. If the prefix argument is positive,
enable the mode, and if it is zero or negative, disable the mode.
If called from Lisp, toggle the mode if ARG is `toggle'. Enable
the mode if ARG is nil, omitted, or is a positive number.
Disable the mode if ARG is a negative number.
To check whether the minor mode is enabled in the current buffer,
evaluate `mastodon-async-mode'.
The mode's hook is called both when the mode is enabled and when
it is disabled.
\(fn &optional ARG)" t nil)
(register-definition-prefixes "mastodon-async" '("mastodon-async--"))
;;;***
;;;### (autoloads nil "mastodon-auth" "mastodon-auth.el" (0 0 0 0))
;;; Generated autoloads from mastodon-auth.el
(register-definition-prefixes "mastodon-auth" '("mastodon-auth-"))
;;;***
;;;### (autoloads nil "mastodon-client" "mastodon-client.el" (0 0
;;;;;; 0 0))
;;; Generated autoloads from mastodon-client.el
(register-definition-prefixes "mastodon-client" '("mastodon-client"))
;;;***
;;;### (autoloads nil "mastodon-discover" "mastodon-discover.el"
;;;;;; (0 0 0 0))
;;; Generated autoloads from mastodon-discover.el
(register-definition-prefixes "mastodon-discover" '("mastodon-discover"))
;;;***
;;;### (autoloads nil "mastodon-http" "mastodon-http.el" (0 0 0 0))
;;; Generated autoloads from mastodon-http.el
(register-definition-prefixes "mastodon-http" '("mastodon-http--"))
;;;***
;;;### (autoloads nil "mastodon-inspect" "mastodon-inspect.el" (0
;;;;;; 0 0 0))
;;; Generated autoloads from mastodon-inspect.el
(register-definition-prefixes "mastodon-inspect" '("mastodon-inspect--"))
;;;***
;;;### (autoloads nil "mastodon-iso" "mastodon-iso.el" (0 0 0 0))
;;; Generated autoloads from mastodon-iso.el
(register-definition-prefixes "mastodon-iso" '("mastodon-iso-639-"))
;;;***
;;;### (autoloads nil "mastodon-media" "mastodon-media.el" (0 0 0
;;;;;; 0))
;;; Generated autoloads from mastodon-media.el
(register-definition-prefixes "mastodon-media" '("mastodon-media--"))
;;;***
;;;### (autoloads nil "mastodon-notifications" "mastodon-notifications.el"
;;;;;; (0 0 0 0))
;;; Generated autoloads from mastodon-notifications.el
(register-definition-prefixes "mastodon-notifications" '("mastodon-notifications--"))
;;;***
;;;### (autoloads nil "mastodon-profile" "mastodon-profile.el" (0
;;;;;; 0 0 0))
;;; Generated autoloads from mastodon-profile.el
(register-definition-prefixes "mastodon-profile" '("mastodon-profile-"))
;;;***
;;;### (autoloads nil "mastodon-search" "mastodon-search.el" (0 0
;;;;;; 0 0))
;;; Generated autoloads from mastodon-search.el
(register-definition-prefixes "mastodon-search" '("mastodon-search--"))
;;;***
;;;### (autoloads nil "mastodon-tl" "mastodon-tl.el" (0 0 0 0))
;;; Generated autoloads from mastodon-tl.el
(register-definition-prefixes "mastodon-tl" '("mastodon-tl-" "with-mastodon-buffer"))
;;;***
;;;### (autoloads nil "mastodon-toot" "mastodon-toot.el" (0 0 0 0))
;;; Generated autoloads from mastodon-toot.el
(add-hook 'mastodon-toot-mode-hook #'mastodon-profile--fetch-server-account-settings-maybe)
(register-definition-prefixes "mastodon-toot" '("mastodon-toot-"))
;;;***
;;;### (autoloads nil "mastodon-views" "mastodon-views.el" (0 0 0
;;;;;; 0))
;;; Generated autoloads from mastodon-views.el
(register-definition-prefixes "mastodon-views" '("mastodon-views-"))
;;;***
;;;### (autoloads nil nil ("mastodon-pkg.el") (0 0 0 0))
;;;***
;; Local Variables:
;; version-control: never
;; no-byte-compile: t
;; no-update-autoloads: t
;; coding: utf-8
;; End:
;;; mastodon-autoloads.el ends here
;;; mastodon-auth.el --- Auth functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017-2019 Johnson Denen
;; Copyright (C) 2021 Abhiseck Paira <abhiseckpaira@disroot.org>
;; Author: Johnson Denen <johnson.denen@gmail.com>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; mastodon-auth.el supports authorizing and authenticating with Mastodon.
;;; Code:
(require 'plstore)
(require 'auth-source)
(require 'json)
(eval-when-compile (require 'subr-x)) ; for if-let
(autoload 'mastodon-client "mastodon-client")
(autoload 'mastodon-client--active-user "mastodon-client")
(autoload 'mastodon-client--form-user-from-vars "mastodon-client")
(autoload 'mastodon-client--make-user-active "mastodon-client")
(autoload 'mastodon-client--store-access-token "mastodon-client")
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--append-query-string "mastodon-http")
(autoload 'mastodon-http--get-json "mastodon-http")
(autoload 'mastodon-http--post "mastodon-http")
(defvar mastodon-instance-url)
(defvar mastodon-client-scopes)
(defvar mastodon-client-redirect-uri)
(defvar mastodon-active-user)
(defgroup mastodon-auth nil
"Authenticate with Mastodon."
:prefix "mastodon-auth-"
:group 'mastodon)
(defvar mastodon-auth-source-file nil
"This variable is obsolete.
This variable currently serves no purpose and will be removed in
the future.")
(defvar mastodon-auth--token-alist nil
"Alist of User access tokens keyed by instance url.")
(defvar mastodon-auth--acct-alist nil
"Alist of account accts (name@domain) keyed by instance url.")
(defvar mastodon-auth--user-unaware
" ** MASTODON.EL - NOTICE **
It appears that you are not aware of the recent developments in
mastodon.el. In short we now require that you also set the
variable `mastodon-active-user' in your init file in addition to
`mastodon-instance-url'.
Please see its documentation to understand what value it accepts
by running M-x describe-variable on it or visiting our web page:
https://codeberg.org/martianh/mastodon.el
We apologize for the inconvenience.
")
(defun mastodon-auth--get-browser-login-url ()
"Return properly formed browser login url."
(mastodon-http--append-query-string
(concat mastodon-instance-url "/oauth/authorize/")
`(("response_type" "code")
("redirect_uri" ,mastodon-client-redirect-uri)
("scope" ,mastodon-client-scopes)
("client_id" ,(plist-get (mastodon-client) :client_id)))))
(defvar mastodon-auth--explanation
(format
"
1. A URL has been copied to your clipboard. Open this URL in a
javascript capable browser and your browser will take you to your
Mastodon instance's login page.
2. Login to your account (%s) and authorize \"mastodon.el\".
3. After authorization you will be presented an authorization
code. Copy this code and paste it in the minibuffer prompt."
(mastodon-client--form-user-from-vars)))
(defun mastodon-auth--show-notice (notice buffer-name &optional ask)
"Display NOTICE to user.
NOTICE is displayed in vertical split occupying 50% of total
width. The buffer name of the buffer being displayed in the
window is BUFFER-NAME.
When optional argument ASK is given which should be a string, use
ASK as the minibuffer prompt. Return whatever user types in
response to the prompt.
When ASK is absent return nil."
(let ((buffer (get-buffer-create buffer-name))
(inhibit-read-only t)
ask-value window)
(set-buffer buffer)
(erase-buffer)
(insert notice)
(fill-region (point-min) (point-max))
(read-only-mode)
(setq window (select-window
(split-window (frame-root-window) nil 'left)
t))
(switch-to-buffer buffer t)
(when ask
(setq ask-value (read-string ask))
(kill-buffer buffer)
(delete-window window))
ask-value))
(defun mastodon-auth--request-authorization-code ()
"Ask authorization code and return it."
(let ((url (mastodon-auth--get-browser-login-url))
authorization-code)
(kill-new url)
(message "%s" url)
(setq authorization-code
(mastodon-auth--show-notice mastodon-auth--explanation
"*mastodon-notice*"
"Authorization Code: "))
authorization-code))
(defun mastodon-auth--generate-token ()
"Generate access_token for the user. Return response buffer."
(let ((authorization-code (mastodon-auth--request-authorization-code)))
(mastodon-http--post
(concat mastodon-instance-url "/oauth/token")
`(("grant_type" . "authorization_code")
("client_secret" . ,(plist-get (mastodon-client) :client_secret))
("client_id" . ,(plist-get (mastodon-client) :client_id))
("code" . ,authorization-code)
("redirect_uri" . ,mastodon-client-redirect-uri))
nil
:unauthenticated)))
(defun mastodon-auth--get-token ()
"Make a request to generate an auth token and return JSON response."
(with-current-buffer (mastodon-auth--generate-token)
(goto-char (point-min))
(re-search-forward "^$" nil 'move)
(let ((json-object-type 'plist)
(json-key-type 'keyword)
(json-array-type 'vector)
(json-string (buffer-substring-no-properties (point) (point-max))))
(json-read-from-string json-string))))
(defun mastodon-auth--access-token ()
"Return the access token to use with `mastodon-instance-url'.
Generate/save token if none known yet."
(cond (mastodon-auth--token-alist
;; user variables are known and
;; initialised already.
(alist-get mastodon-instance-url mastodon-auth--token-alist
nil nil 'equal))
((plist-get (mastodon-client--active-user) :access_token)
;; user variables needs to initialised by reading from
;; plstore.
(push (cons mastodon-instance-url
(plist-get (mastodon-client--active-user) :access_token))
mastodon-auth--token-alist)
(alist-get mastodon-instance-url mastodon-auth--token-alist
nil nil 'equal))
((null mastodon-active-user)
;; user not aware of 2FA related changes and has not set the
;; `mastodon-active-user' properly. Make user aware and error
;; out.
(mastodon-auth--show-notice mastodon-auth--user-unaware
"*mastodon-notice*")
(error "Variables not set properly"))
(t
;; user access-token needs to fetched from the server and
;; stored and variables initialised.
(mastodon-auth--handle-token-response (mastodon-auth--get-token)))))
(defun mastodon-auth--handle-token-response (response)
"Add token RESPONSE to `mastodon-auth--token-alist'.
The token is returned by `mastodon-auth--get-token'.
Handle any errors from the server."
(pcase response
((and (let token (plist-get response :access_token))
(guard token))
(mastodon-client--make-user-active
(mastodon-client--store-access-token token))
(cdar (push (cons mastodon-instance-url token)
mastodon-auth--token-alist)))
(`(:error ,class :error_description ,error)
(error "Mastodon-auth--access-token: %s: %s" class error))
(_ (error "Unknown response from mastodon-auth--get-token!"))))
(defun mastodon-auth--get-account-name ()
"Request user credentials and return an account name."
(alist-get
'acct
(mastodon-http--get-json
(mastodon-http--api
"accounts/verify_credentials")
nil
:silent)))
(defun mastodon-auth--get-account-id ()
"Request user credentials and return an account name."
(alist-get
'id
(mastodon-http--get-json
(mastodon-http--api
"accounts/verify_credentials"))))
(defun mastodon-auth--user-acct ()
"Return a mastodon user acct name."
(or (cdr (assoc mastodon-instance-url mastodon-auth--acct-alist))
(let ((acct (mastodon-auth--get-account-name)))
(push (cons mastodon-instance-url acct) mastodon-auth--acct-alist)
acct)))
(provide 'mastodon-auth)
;;; mastodon-auth.el ends here
;;; mastodon-async.el --- Async streaming functions for mastodon.el -*- lexical-binding: t -*-
;; Copyright (C) 2017 Alex J. Griffith
;; Author: Alex J. Griffith <griffitaj@gmail.com>
;; Maintainer: Marty Hiatt <martianhiatus@riseup.net>
;; Version: 1.0.0
;; Package-Requires: ((emacs "27.1"))
;; Homepage: https://codeberg.org/martianh/mastodon.el
;; This file is not part of GNU Emacs.
;; This file is part of mastodon.el.
;; mastodon.el is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; mastodon.el is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with mastodon.el. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; Rework sync code so it does not mess up the async-buffer
;;; Code:
(require 'mastodon-tl)
(require 'json)
(require 'url-http)
(defvar url-http-end-of-headers)
(autoload 'mastodon-auth--access-token "mastodon-auth")
(autoload 'mastodon-http--api "mastodon-http")
(autoload 'mastodon-http--get-json "mastodon-http")
(autoload 'mastodon-mode "mastodon")
(autoload 'mastodon-notifications--timeline "mastodon-notifications")
(autoload 'mastodon-tl--timeline "mastodon-tl")
(defgroup mastodon-async nil
"An async module for mastodon streams."
:prefix "mastodon-async-"
:group 'external)
;;;###autoload
(define-minor-mode mastodon-async-mode
"Async Mastodon."
:lighter " MasA")
(defvar mastodon-instance-url)
(defvar mastodon-tl--enable-relative-timestamps)
(defvar mastodon-tl--display-media-p)
(defvar mastodon-tl--buffer-spec)
(defvar-local mastodon-async--queue "" ;;"*mastodon-async-queue*"
"The intermediate queue buffer name.")
(defvar-local mastodon-async--buffer "" ;;"*mastodon-async-buffer*"
"User facing output buffer name.")
(defvar-local mastodon-async--http-buffer "" ;;""
"Buffer variable bound to http output.")
(defun mastodon-async--display-http ()
"Display the async HTTP input buffer."
(display-buffer mastodon-async--http-buffer))
(defun mastodon-async--display-buffer ()
"Display the async user facing buffer."
(interactive)
(display-buffer mastodon-async--buffer))
(defun mastodon-async--display-queue ()
"Display the async queue buffer."
(display-buffer mastodon-async--queue))
(defun mastodon-async--stop-http ()
"Stop the http processs and close the async and http buffer."
(interactive)
(let ((inhibit-read-only t))
(stop-process (get-buffer-process mastodon-async--http-buffer))
(delete-process (get-buffer-process mastodon-async--http-buffer))
(kill-buffer mastodon-async--http-buffer)
(setq mastodon-async--http-buffer "")
(when (not (equal "" mastodon-async--queue)) ; error handle on kill async buffer
(kill-buffer mastodon-async--queue))))
(defun mastodon-async--stream-notifications ()
"Open a stream of user notifications."
(interactive)
(mastodon-async--mastodon
"user"
"home"
"notifications"
'mastodon-async--process-queue-string-notifications))
(defun mastodon-async--stream-home ()
"Open a stream of the home timeline."
(interactive)
(mastodon-async--mastodon
"user"
"home"
"home"
'mastodon-async--process-queue-string))
(defun mastodon-async--stream-federated ()
"Open a stream of Federated."
(interactive)
(mastodon-async--mastodon
"public"
"public"
"federated"
'mastodon-async--process-queue-string))
(defun mastodon-async--stream-local ()
"Open a stream of Local."
(interactive)
;; Need to add another layer of filtering for this to work
;; apparently it the local flag does not work
(mastodon-async--mastodon
"public"
"public?local=true"
"local"
'mastodon-async--process-queue-local-string))
(defun mastodon-async--mastodon (endpoint timeline name filter)
"Make sure that the previous async process has been closed.
Then start an async stream at ENDPOINT filtering toots
using FILTER.
TIMELINE is a specific target, such as federated or home.
NAME is the center portion of the buffer name for
*mastodon-async-buffer and *mastodon-async-queue."
(ignore timeline) ;; TODO: figure out what this is meant to be used for
(let ((buffer (mastodon-async--start-process
endpoint filter name)))
(with-current-buffer buffer
(mastodon-async--display-buffer)
(goto-char (point-max))
(goto-char 1))))
(defun mastodon-async--get (url callback)
"An async GET request to URL with CALLBACK."
(let ((url-request-method "GET")
(url-request-extra-headers
`(("Authorization" .
,(concat
"Bearer "
(mastodon-auth--access-token))))))
(url-retrieve url callback)))
(defun mastodon-async--set-http-buffer (buffer http-buffer)
"Initialize for BUFFER a local variable `mastodon-async--http-buffer'.
HTTP-BUFFER is the initializing value. Use this funcion if HTTP-BUFFER
is not known when `mastodon-async--setup-buffer' is called."
(with-current-buffer (get-buffer-create buffer)
(setq mastodon-async--http-buffer http-buffer)))
(defun mastodon-async--set-local-variables (buffer
http-buffer
buffer-name
queue-name)
"Set local variables for BUFFER, HTTP-BUFFER, BUFFER-NAME, and QUEUE-NAME."
(with-current-buffer (get-buffer-create buffer)
(let ((value mastodon-instance-url))
(make-local-variable 'mastodon-instance-url)
(setq-local mastodon-instance-url value))
(setq mastodon-async--http-buffer http-buffer)
(setq mastodon-async--buffer buffer-name)
(setq mastodon-async--queue queue-name)))
(defun mastodon-async--setup-http (http-buffer name)
"Add local variables to HTTP-BUFFER.
NAME is used to generate the display buffer and the queue."
(let ((queue-name (concat " *mastodon-async-queue-" name "-"
mastodon-instance-url "*"))
(buffer-name (concat "*mastodon-async-display-" name "-"
mastodon-instance-url "*")))
(mastodon-async--set-local-variables http-buffer http-buffer
buffer-name queue-name)))
(defun mastodon-async--setup-queue (http-buffer name)
"Set up HTTP-BUFFER buffer for the async queue.
NAME is used to generate the display buffer and the queue."
(let ((queue-name (concat " *mastodon-async-queue-" name "-"
mastodon-instance-url "*"))
(buffer-name(concat "*mastodon-async-display-" name "-"
mastodon-instance-url "*")))
(mastodon-async--set-local-variables queue-name http-buffer
buffer-name queue-name)
queue-name))
(defun mastodon-async--setup-buffer (http-buffer name endpoint)
"Set up the buffer timeline like `mastodon-tl--init'.
HTTP-BUFFER the name of the http-buffer, if unknown, set to...
NAME is the name of the stream for the buffer name.
ENDPOINT is the endpoint for the stream and timeline."
(let ((queue-name (concat " *mastodon-async-queue-" name "-"
mastodon-instance-url "*"))
(buffer-name (concat "*mastodon-async-display-" name "-"
mastodon-instance-url "*"))
;; if user stream, we need "timelines/home" not "timelines/user"
;; if notifs, we need "notifications" not "timelines/notifications"
(endpoint (cond
((equal name "notifications") "notifications")
((equal name "home") "timelines/home")
(t (format "timelines/%s" endpoint)))))
(mastodon-async--set-local-variables buffer-name http-buffer
buffer-name queue-name)
;; Similar to timeline init.
(with-current-buffer (get-buffer-create buffer-name)
(setq inhibit-read-only t) ; for home timeline?
(make-local-variable 'mastodon-tl--enable-relative-timestamps)
(make-local-variable 'mastodon-tl--display-media-p)
(message (mastodon-http--api endpoint))
(if (equal name "notifications")
(mastodon-notifications--timeline
(mastodon-http--get-json
(mastodon-http--api "notifications")))
(mastodon-tl--timeline (mastodon-http--get-json
(mastodon-http--api endpoint))))
(mastodon-mode)
(mastodon-tl--set-buffer-spec buffer-name
endpoint
(if (equal name "notifications")
'mastodon-notifications--timeline
'mastodon-tl--timeline))
(setq-local mastodon-tl--enable-relative-timestamps nil)
(setq-local mastodon-tl--display-media-p t)
(current-buffer))))
(defun mastodon-async--start-process (endpoint filter &optional name)
"Start an async mastodon stream at ENDPOINT.
Filter the toots using FILTER.
NAME is used for the queue and display buffer."
(let* ((stream (concat "streaming/" endpoint))
(async-queue (mastodon-async--setup-queue "" (or name stream)))
(async-buffer (mastodon-async--setup-buffer "" (or name stream) endpoint))
(http-buffer (mastodon-async--get
(mastodon-http--api stream)
(lambda (status)
(ignore status)
(message "HTTP SOURCE CLOSED")))))
(mastodon-async--setup-http http-buffer (or name stream))
(mastodon-async--set-http-buffer async-buffer http-buffer)
(mastodon-async--set-http-buffer async-queue http-buffer)
(set-process-filter (get-buffer-process http-buffer)
(mastodon-async--http-hook filter))
http-buffer))
(defun mastodon-async--http-hook (filter)
"Return a lambda with a custom FILTER for processing toots."
(let ((filter filter))
(lambda (proc data)
(with-current-buffer (process-buffer proc)
(let* ((string
(mastodon-async--stream-filter
(mastodon-async--http-layer proc data)))
(queue-string (mastodon-async--cycle-queue string)))
(when queue-string
(mastodon-async--output-toot
(funcall filter queue-string))))))))
(defun mastodon-async--process-queue-string (string)
"Parse the output STRING of the queue buffer, returning only update events."
(let ((split-strings (split-string string "\n" t)))
(when split-strings ; do nothing if we get nothing; just postpones the error
(let ((event-type (replace-regexp-in-string
"^event: " ""
(car split-strings)))
(data (replace-regexp-in-string
"^data: " "" (cadr split-strings))))
(when (equal "update" event-type)
;; in some casses the data is not fully formed
;; for now return nil if malformed using `ignore-errors'
(ignore-errors (json-read-from-string data)))))))
(defun mastodon-async--process-queue-string-notifications (string)
"Parse the output STRING of the queue buffer, returning only notification events."
;; NB notification events in streams include follow requests
(let* ((split-strings (split-string string "\n" t))
(event-type (replace-regexp-in-string
"^event: " ""
(car split-strings)))
(data (replace-regexp-in-string
"^data: " "" (cadr split-strings))))
(when (equal "notification" event-type)
;; in some casses the data is not fully formed
;; for now return nil if malformed using `ignore-errors'
(ignore-errors (json-read-from-string data)))))
(defun mastodon-async--process-queue-local-string (string)
"Use STRING to limit the public endpoint to displaying local steams only."
(let ((json (mastodon-async--process-queue-string string)))
(when json
(when (mastodon-async--account-local-p json)
json))))
(defun mastodon-async--account-local-p (json)
"Test JSON to see if account is local."
(not (string-match-p
"@"
(alist-get 'acct (alist-get 'account json)))))
(defun mastodon-async--output-toot (toot)
"Process TOOT and prepend it to the async user-facing buffer."
(if (not (bufferp (get-buffer mastodon-async--buffer)))
(mastodon-async--stop-http)
(when toot
(with-current-buffer mastodon-async--buffer
(let* ((inhibit-read-only t)
(old-max (point-max))
(previous (point))
(mastodon-tl--enable-relative-timestamps t)
(mastodon-tl--display-media-p t))
(goto-char (point-min))
(if (equal (buffer-name)
(concat "*mastodon-async-display-notifications-"
mastodon-instance-url "*"))
(mastodon-notifications--timeline (list toot))
(mastodon-tl--timeline (list toot)))
(if (equal previous 1)
(goto-char 1)
(goto-char (+ previous (- (point-max) old-max)))))))))
(defun mastodon-async--cycle-queue (string)
"Append the most recent STRING from http buffer to queue buffer.
Then determine if a full message has been recived. If so return it.
Full messages are seperated by two newlines"
(with-current-buffer mastodon-async--queue
(goto-char (max-char))
(insert (decode-coding-string string 'utf-8))
(goto-char 0)
(let ((next (re-search-forward "\n\n" nil t)))
(when next
(let ((return-string (buffer-substring 1 next))
(inhibit-read-only t))
(delete-region 1 next)
return-string)))))
(defun mastodon-async--http-layer (proc data)
"Passes PROC and DATA to ‘url-http-generic-filter’.
It then processes its output."
(with-current-buffer (process-buffer proc)
(let ((start (max 1 (- (point-max) 2))))
(url-http-generic-filter proc data)
(when (> url-http-end-of-headers start)
(setq start url-http-end-of-headers))
(let ((end (- (point-max) 2)))
(buffer-substring start end)))))
(defun mastodon-async--stream-filter (string)
"Remove comments from STRING."
(replace-regexp-in-string "^:.*\n" "" string))
(provide 'mastodon-async)
;;; mastodon-async.el ends here
This is the file .../info/dir, which contains the
topmost node of the Info hierarchy, called (dir)Top.
The first time you invoke Info you start off looking at this node.
File: dir, Node: Top This is the top of the INFO tree
This (the Directory node) gives a menu of major topics.
Typing "q" exits, "H" lists all Info commands, "d" returns here,
"h" gives a primer for first-timers,
"mEmacs<Return>" visits the Emacs manual, etc.
In Emacs, you can click mouse button 2 on a menu item or cross reference
to select it.
* Menu:
Emacs
* Mastodon: (mastodon). Client for Mastodon on ActivityPub networks.
Good signature from 066DAFCB81E42C40 GNU ELPA Signing Agent (2019) <elpasign@elpa.gnu.org> (trust undefined) created at 2023-05-16T05:05:02-0400 using RSA
;;; ement-tests.el --- Tests for Ement.el -*- lexical-binding: t; -*-
;; Copyright (C) 2023 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;;
;;; Code:
(require 'ert)
(require 'map)
(require 'ement-lib)
;;;; Tests
(ert-deftest ement--format-body-mentions ()
(let ((room (make-ement-room
:members (map-into
`(("@foo:matrix.org" . ,(make-ement-user :id "@foo:matrix.org"
:displayname "foo"))
("@bar:matrix.org" . ,(make-ement-user :id "@bar:matrix.org"
:displayname "bar")))
'(hash-table :test equal)))))
(should (equal (ement--format-body-mentions "@foo: hi" room)
"<a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a>: hi"))
(should (equal (ement--format-body-mentions "@foo:matrix.org: hi" room)
"<a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a>: hi"))
(should (equal (ement--format-body-mentions "foo: hi" room)
"<a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a>: hi"))
(should (equal (ement--format-body-mentions "@foo and @bar:matrix.org: hi" room)
"<a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a> and <a href=\"https://matrix.to/#/@bar:matrix.org\">bar</a>: hi"))
(should (equal (ement--format-body-mentions "foo: how about you and @bar ..." room)
"<a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a>: how about you and <a href=\"https://matrix.to/#/@bar:matrix.org\">bar</a> ..."))
(should (equal (ement--format-body-mentions "Hello, @foo:matrix.org." room)
"Hello, <a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a>."))
(should (equal (ement--format-body-mentions "Hello, @foo:matrix.org, how are you?" room)
"Hello, <a href=\"https://matrix.to/#/@foo:matrix.org\">foo</a>, how are you?"))))
(provide 'ement-tests)
;;; ement-tests.el ends here
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xml:space="preserve"
id="svg4768"
viewBox="0.171 0.201 512 512"
height="48"
width="48"
version="1.0"
inkscape:version="0.48.4 r9939"
sodipodi:docname="logo2.svg"
inkscape:export-filename="/home/me/src/emacs/ement.el/images/logo-128px.png"
inkscape:export-xdpi="240"
inkscape:export-ydpi="240"><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1173"
id="namedview72"
showgrid="false"
inkscape:zoom="4.9166667"
inkscape:cx="-8.3796227"
inkscape:cy="-20.646658"
inkscape:window-x="1920"
inkscape:window-y="6"
inkscape:window-maximized="1"
inkscape:current-layer="svg4768" /><metadata
id="metadata70"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><!-- Gnu Emacs Icon
Copyright (C) 2008-2017 Free Software Foundation, Inc.
Author: Nicolas Petton <nicolas@petton.fr>
This file is part of GNU Emacs.
GNU Emacs is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
GNU Emacs is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with GNU Emacs. If not, see <http://www.gnu.org/licenses/>.
--><!-- Created with Inkscape (http://www.inkscape.org/) --><defs
id="defs4770"><linearGradient
id="linearGradient3961"><stop
id="stop3963"
offset="0"
style="stop-color:#ffffff;stop-opacity:1;" /><stop
id="stop3965"
offset="1"
style="stop-color:#0dbd8b;stop-opacity:1;" /></linearGradient><linearGradient
id="linearGradient3874"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1;"
id="stop3876" /><stop
id="stop3882"
style="stop-color:#000000;stop-opacity:1;"
offset="1" /></linearGradient><linearGradient
id="linearGradient3866"><stop
style="stop-color:#00ff18;stop-opacity:1;"
offset="0"
id="stop3868" /><stop
style="stop-color:#000000;stop-opacity:1;"
offset="1"
id="stop3870" /></linearGradient><linearGradient
id="linearGradient3817"><stop
id="stop3819"
style="stop-color:#00ff0a;stop-opacity:1;"
offset="0" /><stop
id="stop3823"
style="stop-color:#000000;stop-opacity:0.99215686;"
offset="1" /></linearGradient><linearGradient
id="linearGradient4292"><stop
id="stop4294"
offset="0"
style="stop-color:#411f5d;stop-opacity:1" /><stop
id="stop4296"
offset="1"
style="stop-color:#5b2a85;stop-opacity:1" /></linearGradient><linearGradient
id="linearGradient4284"><stop
offset="0"
style="stop-color:#8381c5;stop-opacity:1"
id="stop4286" /><stop
id="stop4290"
style="stop-color:#7e55b3;stop-opacity:0.99607843"
offset="0.56639391" /><stop
offset="1"
style="stop-color:#a52ecb;stop-opacity:0.99215686"
id="stop4288" /></linearGradient><linearGradient
id="linearGradient4898"><stop
id="stop4278"
style="stop-color:#bab8db;stop-opacity:1"
offset="0" /><stop
id="stop4280"
style="stop-color:#5955a9;stop-opacity:0.99159664"
offset="1" /></linearGradient><linearGradient
id="linearGradient3294"><stop
offset="0"
style="stop-color:#6376e6;stop-opacity:1"
id="stop3296" /><stop
offset="0.50094414"
style="stop-color:#222989;stop-opacity:1"
id="stop3302" /><stop
offset="1"
style="stop-color:#00003d;stop-opacity:1"
id="stop3298" /></linearGradient><linearGradient
id="linearGradient3284"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop3286" /><stop
offset="0.84845906"
style="stop-color:#000000;stop-opacity:0.49803922"
id="stop3292" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop3288" /></linearGradient><linearGradient
id="linearGradient3274"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop3276" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop3278" /></linearGradient><linearGradient
id="linearGradient3262"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop3264" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop3266" /></linearGradient><linearGradient
id="linearGradient3242"><stop
offset="0"
style="stop-color:#282828;stop-opacity:1"
id="stop3244" /><stop
offset="0.39253417"
style="stop-color:#808080;stop-opacity:1"
id="stop3252" /><stop
offset="1"
style="stop-color:#d9d9d9;stop-opacity:1"
id="stop3246" /></linearGradient><linearGradient
id="linearGradient3202"><stop
offset="0"
style="stop-color:#2b2b2b;stop-opacity:1"
id="stop3204" /><stop
offset="0.5"
style="stop-color:#828383;stop-opacity:1"
id="stop3250" /><stop
offset="1"
style="stop-color:#dadbdb;stop-opacity:1"
id="stop3206" /></linearGradient><linearGradient
id="linearGradient4966"><stop
offset="0"
style="stop-color:#b6b3d8;stop-opacity:1"
id="stop4968" /><stop
offset="1"
style="stop-color:#b6b3d8;stop-opacity:0"
id="stop4970" /></linearGradient><linearGradient
id="linearGradient4938"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop4940" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop4942" /></linearGradient><linearGradient
id="linearGradient4282"><stop
offset="0"
style="stop-color:#bab8db;stop-opacity:1"
id="stop4900" /><stop
offset="1"
style="stop-color:#5955a9;stop-opacity:0.99159664"
id="stop4902" /></linearGradient><linearGradient
id="linearGradient4876"><stop
offset="0"
style="stop-color:#d3d2e8;stop-opacity:1"
id="stop4878" /><stop
offset="1"
style="stop-color:#5955a9;stop-opacity:0.99159664"
id="stop4880" /></linearGradient><radialGradient
gradientTransform="matrix(0.6817439,0,0,0.5905355,-3.8523706,-28.935273)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient4898"
id="radialGradient4892"
fy="-108.96888"
fx="20.951529"
r="266.76535"
cy="-108.96888"
cx="20.951529" /><radialGradient
gradientTransform="matrix(1,0,0,0.1854103,0,383.88493)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient4938"
id="radialGradient4944"
fy="471.26172"
fx="233.8876"
r="170.49393"
cy="471.26172"
cx="233.8876" /><radialGradient
gradientTransform="matrix(1,0,0,0.9121621,0,32.654948)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient4966"
id="radialGradient4972"
fy="371.76376"
fx="299.70135"
r="76.696358"
cy="371.76376"
cx="299.70135" /><radialGradient
gradientTransform="matrix(0.414705,0.3300575,-0.5059004,0.6356454,346.95314,49.479585)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3202"
id="radialGradient3210"
fy="390.45248"
fx="289.44067"
r="17.67668"
cy="390.45248"
cx="289.44067" /><radialGradient
gradientTransform="matrix(0.414705,0.3300575,-0.5059004,0.6356454,448.41009,-65.398074)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3202"
id="radialGradient3238"
fy="382.14804"
fx="283.50717"
r="17.67668"
cy="382.14804"
cx="283.50717" /><radialGradient
gradientTransform="matrix(-6.5565014e-2,-5.9721765e-2,1.6871024,-1.8521705,171.90774,540.51473)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3242"
id="radialGradient3248"
fy="181.18982"
fx="418.45551"
r="63.068935"
cy="181.18982"
cx="418.45551" /><radialGradient
gradientTransform="matrix(0.4055116,-3.3440123e-2,0.1034174,4.3988695,177.23251,-1191.6649)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3262"
id="radialGradient3268"
fy="357.33591"
fx="354.51709"
r="33.712105"
cy="357.33591"
cx="354.51709" /><radialGradient
gradientTransform="matrix(-0.1339874,-0.1146812,0.3079048,-0.3597394,444.23592,395.03849)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3274"
id="radialGradient3280"
fy="223.55537"
fx="510.58469"
r="132.28336"
cy="223.55537"
cx="510.58469" /><radialGradient
gradientTransform="matrix(-1.2497569,1.3798305,-9.6289463e-2,-7.2974479e-2,674.3826,-70.590682)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3284"
id="radialGradient3290"
fy="-158.17821"
fx="284.4671"
r="110.2972"
cy="-158.17821"
cx="284.4671" /><radialGradient
gradientTransform="matrix(-0.1008165,-8.0872321e-2,1.0745309,-1.3395252,13.843287,784.79288)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3294"
id="radialGradient3300"
fy="356.62274"
fx="425.51019"
r="143.34167"
cy="356.62274"
cx="425.51019" /><filter
height="1.088351"
y="-0.044175496"
width="1.0892536"
x="-0.044626798"
id="filter4350"
style="color-interpolation-filters:sRGB"><feGaussianBlur
id="feGaussianBlur4352"
stdDeviation="8.7848425" /></filter><linearGradient
y2="300.73987"
x2="236.61363"
y1="-161.8512"
x1="-122.20192"
spreadMethod="pad"
gradientTransform="matrix(0.87385837,0,0,0.82818057,246.00762,250.28138)"
gradientUnits="userSpaceOnUse"
id="linearGradient4245"
xlink:href="#linearGradient3817" /><linearGradient
y2="66.018341"
x2="173.94518"
y1="396.6066"
x1="447.80933"
gradientTransform="matrix(0.98684959,0,0,0.98684959,3.0344187,2.5250397)"
gradientUnits="userSpaceOnUse"
id="linearGradient4247"
xlink:href="#linearGradient4292" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3961"
id="radialGradient3959"
cx="1136.9111"
cy="38.175797"
fx="1136.9111"
fy="38.175797"
r="233.11514"
gradientTransform="matrix(1,0,0,1.010216,-880.74005,217.63519)"
gradientUnits="userSpaceOnUse" /><filter
inkscape:collect="always"
id="filter3894"><feGaussianBlur
inkscape:collect="always"
stdDeviation="7.6798908"
id="feGaussianBlur3896" /></filter><filter
color-interpolation-filters="sRGB"
inkscape:collect="always"
id="filter3894-3"><feGaussianBlur
inkscape:collect="always"
stdDeviation="7.6798908"
id="feGaussianBlur3896-1" /></filter></defs><rect
style="fill:none;display:none"
id="rect4772"
y="0.20100001"
x="0.171"
height="512"
width="512" /><g
style="display:none"
id="g4788"><g
style="display:inline"
id="g4790" /></g><g
style="display:none"
id="g4806"><g
style="display:inline"
id="g4808"><path
style="fill:#050505;display:none"
id="path4810"
d="M 349.098,256.651 C 348.833,256.397 386.735,284.256 388.519,281.663 C 394.881,272.411 470.565,188.526 473.303,165.427 C 473.545,163.424 472.787,161.331 472.787,161.331 C 472.787,161.331 471.597,161.187 466.462,157.017 C 463.77,154.825 460.979,152.436 460.979,152.436 C 444.925,153.434 403.094,193.995 349.917,256.004" /></g></g><path
d="m 438.46612,112.92247 c 80.3259,102.29207 63.35739,249.67467 -37.9002,329.18824 C 299.30833,521.62427 152.10573,503.15872 71.779825,400.86665 -8.5460891,298.57456 8.4225064,151.19191 109.6801,71.678342 210.93769,-7.8352228 358.14021,10.630379 438.46612,112.92247 z"
id="path4235"
style="fill:#0dbd8b;fill-opacity:1;stroke:#000000;stroke-width:13.33333302;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
inkscape:connector-curvature="0" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -862.34934,-104.26336 c 0,-16.20664 13.138,-29.34464 29.34471,-29.34464 108.04422,0 195.63103,87.586807 195.63103,195.631095 0,16.206636 -13.138,29.344642 -29.34464,29.344642 -16.20664,0 -29.34464,-13.138006 -29.34464,-29.344642 0,-75.631014 -61.3108,-136.941754 -136.94175,-136.941754 -16.20671,0 -29.34471,-13.138067 -29.34471,-29.344701 z"
id="path4" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -725.40758,326.12504 c 0,16.20664 -13.13801,29.34464 -29.34464,29.34464 -108.04429,0 -195.63112,-87.58681 -195.63112,-195.63103 0,-16.20664 13.138,-29.34471 29.3447,-29.34471 16.2066,0 29.3447,13.13807 29.3447,29.34471 0,75.63095 61.3107,136.94175 136.94172,136.94175 16.20663,0 29.34464,13.138 29.34464,29.34464 z"
id="path6" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -1009.0726,179.40172 c -16.2067,0 -29.3447,-13.13801 -29.3447,-29.34464 0,-108.044293 87.58676,-195.631101 195.6311,-195.631101 16.20663,0 29.34464,13.138002 29.34464,29.344639 0,16.20663681 -13.13801,29.344702 -29.34464,29.344702 -75.63104,0 -136.94174,61.310741 -136.94174,136.94176 0,16.20663 -13.1381,29.34464 -29.34466,29.34464 z"
id="path8" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -578.68426,42.459959 c 16.20664,0 29.34464,13.138002 29.34464,29.344639 0,108.044292 -87.58681,195.631102 -195.63103,195.631102 -16.20664,0 -29.34471,-13.138 -29.34471,-29.34464 0,-16.20664 13.13807,-29.34464 29.34471,-29.34464 75.63095,0 136.94175,-61.3108 136.94175,-136.941822 0,-16.206637 13.138,-29.344639 29.34464,-29.344639 z"
id="path10" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -258.96103,188.38006 c -5.92272,0 -10.72401,-4.80129 -10.72401,-10.72404 0,-39.48479 32.00863,-71.49343 71.49346,-71.49343 5.92272,0 10.72401,4.80129 10.72401,10.72401 0,5.92273 -4.80129,10.72401 -10.72401,10.72401 -27.63938,0 -50.04542,22.40606 -50.04542,50.04541 0,5.92275 -4.80131,10.72404 -10.72403,10.72404 z"
id="path4-3" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -187.33996,241.78363 c 0,5.92274 -4.80129,10.72402 -10.72401,10.72402 -39.48482,0 -71.49346,-32.00861 -71.49346,-71.49346 0,-5.92272 4.80129,-10.72401 10.72401,-10.72401 5.92273,0 10.72403,4.80129 10.72403,10.72401 0,27.63939 22.40604,50.04541 50.04542,50.04541 5.92272,0 10.72401,4.80133 10.72401,10.72402 z"
id="path8-7" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -258.74453,313.27152 c -5.92272,0 -10.72401,-4.80129 -10.72401,-10.72401 0,-39.48483 32.00863,-71.49347 71.49344,-71.49347 5.92272,0 10.72403,4.80129 10.72403,10.72401 0,5.92273 -4.80131,10.72402 -10.72403,10.72402 -27.63937,0 -50.04542,22.40605 -50.04542,50.04544 0,5.92272 -4.80129,10.72401 -10.72401,10.72401 z"
id="path10-5" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -187.12346,367.23573 c 0,5.92272 -4.80128,10.724 -10.72401,10.724 -39.48482,0 -71.49346,-32.00863 -71.49346,-71.49343 0,-5.92272 4.80129,-10.72403 10.72403,-10.72403 5.92271,0 10.72404,4.80131 10.72404,10.72403 0,27.63936 22.40601,50.04542 50.04539,50.04542 5.92273,0 10.72401,4.80128 10.72401,10.72401 z"
id="path6-6" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.29136932;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 629.26349,134.49819 c -5.17912,5.17913 -13.57608,5.17911 -18.75524,-3e-5 -34.52746,-34.527469 -34.52746,-90.507338 4e-5,-125.0348349 5.17912,-5.1791201 13.57609,-5.1791215 18.75521,2.1e-6 5.17912,5.1791198 5.1791,13.5760858 -10e-6,18.7551968 -24.16926,24.16926 -24.16922,63.355176 0,87.524396 5.17914,5.17914 5.17912,13.57614 0,18.75527 z"
id="path4-3-3" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.41281462;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 740.87146,587.17776 c -10.37726,0 -18.78964,-8.41245 -18.78964,-18.78967 0,-69.18171 56.08261,-125.26432 125.26429,-125.26432 10.37724,0 18.78967,8.41239 18.78967,18.78963 0,10.37726 -8.41243,18.78964 -18.78967,18.78964 -48.42717,0 -87.68502,39.25785 -87.68502,87.68505 0,10.37722 -8.41238,18.78967 -18.78963,18.78967 z"
id="path10-5-6" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.29136932;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 630.7519,243.88245 c -5.17914,5.17911 -13.5761,5.17911 -18.75523,0 -34.52748,-34.52751 -34.5275,-90.50737 0,-125.03487 5.17912,-5.1791 13.57607,-5.17908 18.75518,3e-5 5.17917,5.17907 5.17918,13.57606 5e-5,18.75519 -24.16926,24.16926 -24.16924,63.35515 2e-5,87.5244 5.17912,5.17912 5.17907,13.57615 -2e-5,18.75518 z"
id="path8-7-5" /><g
id="g3921"
transform="matrix(1.2623093,0,0,1.2623093,-22.620675,-167.67864)"><path
id="path4-9"
d="m -567.88395,525.90207 c 0,-8.09524 6.56246,-14.6577 14.65773,-14.6577 53.96826,0 97.71801,43.74975 97.71801,97.71804 0,8.09524 -6.56246,14.65769 -14.65769,14.65769 -8.09525,0 -14.6577,-6.56245 -14.6577,-14.65769 0,-37.7778 -30.62484,-68.40262 -68.40262,-68.40262 -8.09527,0 -14.65773,-6.56248 -14.65773,-14.65772 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /><path
id="path6-1"
d="m -499.48133,740.88175 c 0,8.09524 -6.56245,14.65768 -14.65769,14.65768 -53.96829,0 -97.71805,-43.74974 -97.71805,-97.718 0,-8.09524 6.56246,-14.65772 14.65772,-14.65772 8.09523,0 14.65773,6.56248 14.65773,14.65772 0,37.77778 30.62479,68.40262 68.4026,68.40262 8.09524,0 14.65769,6.56245 14.65769,14.6577 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /><path
id="path8-2"
d="m -641.17244,667.59321 c -8.09527,0 -14.65773,-6.56245 -14.65773,-14.65769 0,-53.96829 43.74973,-97.71804 97.71804,-97.71804 8.09524,0 14.6577,6.56246 14.6577,14.6577 0,8.09524 -6.56246,14.65772 -14.6577,14.65772 -37.77782,0 -68.40261,30.62481 -68.40261,68.40262 0,8.09524 -6.5625,14.65769 -14.6577,14.65769 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /><path
id="path10-7"
d="m -426.19279,599.19059 c 8.09524,0 14.65769,6.56246 14.65769,14.65769 0,53.9683 -43.74975,97.71805 -97.71801,97.71805 -8.09524,0 -14.65772,-6.56246 -14.65772,-14.65769 0,-8.09525 6.56248,-14.6577 14.65772,-14.6577 37.77778,0 68.40262,-30.62484 68.40262,-68.40266 0,-8.09523 6.56245,-14.65769 14.6577,-14.65769 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /></g><path
d="m 132.03775,284.66555 c 0,0 8.69652,10.02068 21.79702,20.99902 5.30536,4.44597 25.63519,21.13925 42.51032,31.94399 0,0 20.48551,13.26291 32.83983,18.96361 12.92683,5.96487 21.0213,8.15057 27.52118,6.27789 0.75681,-0.95865 4.61173,-3.1871 2.08279,-9.37015 -6.46553,-15.80783 -20.22633,-27.87882 -45.24183,-53.97806 -27.3683,-29.31592 -32.05255,-43.48826 -33.2541,-52.33151 -0.94007,-8.69226 12.20528,-16.994 42.2872,-5.80372 15.42502,5.36496 65.45708,36.83202 65.45708,36.83202 -6.94024,-20.50721 -20.6055,-58.02276 -23.29074,-65.85827 -2.35514,-6.87215 -6.3377,-17.65607 -3.82376,-23.37085 2.13538,-5.76446 11.63777,-3.06713 16.01947,-0.63418 14.13893,7.80833 31.59768,21.33241 45.91754,35.21052 7.19793,6.97589 8.80422,7.67024 8.80422,7.67024 11.40065,8.15653 24.5282,7.90359 32.44151,-5.37384 8.40733,-13.22288 2.06338,-32.27651 -12.20417,-42.14412 -13.43576,-9.29229 -48.78214,-28.72642 -48.78214,-28.72642 34.14941,33.56336 39.25098,39.7951 38.24337,45.86431 -0.59509,3.58428 -5.6341,4.27584 -16.13816,-3.27885 -11.43561,-8.22465 -33.80314,-26.72533 -33.80314,-26.72533 -20.767,-18.43865 -34.65312,-32.16894 -47.56281,-27.5421 -8.43403,3.02279 -7.90747,13.678 -8.62701,19.29852 -1.16084,21.87146 7.58578,43.04002 12.63994,57.24178 1.90166,5.3435 9.28085,19.22246 9.28085,19.22246 -29.51684,-32.95272 -61.63954,-45.89324 -85.89923,-48.0673 -28.77557,-1.12189 -38.64387,21.97216 -18.16598,62.74587 12.09509,24.0826 18.24712,35.87235 47.65908,60.42484 16.74943,15.03619 18.74503,18.05128 17.59938,19.51384 -1.61292,2.05912 -24.04912,-11.25556 -30.33696,-14.72806 -15.99635,-8.83409 -55.77276,-34.14832 -55.97075,-34.27615 z"
id="path4237-0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:2.13333344;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;filter:url(#filter3894)"
inkscape:connector-curvature="0"
transform="matrix(-1.3538609,0,0,-1.3538609,693.74978,1158.395)" /><path
d="m 132.03775,284.66555 c 0,0 8.69652,10.02068 21.79702,20.99902 5.30536,4.44597 25.63519,21.13925 42.51032,31.94399 0,0 20.48551,13.26291 32.83983,18.96361 12.92683,5.96487 21.0213,8.15057 27.52118,6.27789 0.75681,-0.95865 4.61173,-3.1871 2.08279,-9.37015 -6.46553,-15.80783 -20.22633,-27.87882 -45.24183,-53.97806 -27.3683,-29.31592 -32.05255,-43.48826 -33.2541,-52.33151 -0.94007,-8.69226 12.20528,-16.994 42.2872,-5.80372 15.42502,5.36496 65.45708,36.83202 65.45708,36.83202 -6.94024,-20.50721 -20.6055,-58.02276 -23.29074,-65.85827 -2.35514,-6.87215 -6.3377,-17.65607 -3.82376,-23.37085 2.13538,-5.76446 11.63777,-3.06713 16.01947,-0.63418 14.13893,7.80833 31.59768,21.33241 45.91754,35.21052 7.19793,6.97589 8.80422,7.67024 8.80422,7.67024 11.40065,8.15653 24.5282,7.90359 32.44151,-5.37384 8.40733,-13.22288 2.06338,-32.27651 -12.20417,-42.14412 -13.43576,-9.29229 -48.78214,-28.72642 -48.78214,-28.72642 34.14941,33.56336 39.25098,39.7951 38.24337,45.86431 -0.59509,3.58428 -5.6341,4.27584 -16.13816,-3.27885 -11.43561,-8.22465 -33.80314,-26.72533 -33.80314,-26.72533 -20.767,-18.43865 -34.65312,-32.16894 -47.56281,-27.5421 -8.43403,3.02279 -7.90747,13.678 -8.62701,19.29852 -1.16084,21.87146 7.58578,43.04002 12.63994,57.24178 1.90166,5.3435 9.28085,19.22246 9.28085,19.22246 -29.51684,-32.95272 -61.63954,-45.89324 -85.89923,-48.0673 -28.77557,-1.12189 -38.64387,21.97216 -18.16598,62.74587 12.09509,24.0826 18.24712,35.87235 47.65908,60.42484 16.74943,15.03619 18.74503,18.05128 17.59938,19.51384 -1.61292,2.05912 -24.04912,-11.25556 -30.33696,-14.72806 -15.99635,-8.83409 -55.77276,-34.14832 -55.97075,-34.27615 z"
id="path4237-0-2"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:2.13333344;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;filter:url(#filter3894-3)"
inkscape:connector-curvature="0"
transform="matrix(1.3538609,0,0,1.3538609,-52.89981,465.82444)" /><path
d="m -269.87161,757.91183 c 0,0 13.23506,0.93627 30.26137,-0.56431 6.89523,-0.60768 33.07453,-3.17912 52.64715,-7.47152 0,0 23.86373,-5.10715 36.63057,-9.81197 13.35843,-4.92285 20.62761,-9.10098 23.89954,-15.02127 -0.14269,-1.21302 1.00739,-5.51461 -5.15294,-8.09846 -15.74964,-6.60595 -34.01548,-5.41108 -70.15906,-6.1774 -40.0818,-1.37718 -53.41542,-8.08627 -60.51817,-13.48977 -6.81108,-5.48163 -3.38613,-20.64701 25.79772,-34.00541 14.70074,-7.11354 72.32932,-20.24098 72.32932,-20.24098 -19.40827,-9.59329 -55.59858,-26.458 -63.03787,-30.09979 -6.52468,-3.19401 -16.96616,-8.00329 -19.2295,-13.82188 -2.56614,-5.58603 6.06036,-10.39793 10.87905,-11.77591 15.51905,-4.47641 37.42722,-7.25864 57.3662,-7.571 10.0224,-0.15701 11.64921,-0.80185 11.64921,-0.80185 13.82901,-2.29394 22.93273,-11.75538 19.13973,-26.73949 -3.40511,-15.29487 -21.36391,-24.28197 -38.43005,-21.17074 -16.07116,2.92988 -54.80683,14.18154 -54.80683,14.18154 47.88016,-0.4144 55.89402,0.38474 59.47311,5.38881 2.11368,2.95526 -0.96045,7.00739 -13.7299,9.09291 -13.90189,2.27049 -42.80009,5.00476 -42.80009,5.00476 -27.72258,1.6464 -47.25033,1.75659 -53.10719,14.15679 -3.82632,8.10119 4.08038,15.26323 7.5459,19.74633 14.64462,16.28629 35.79785,25.06993 49.41383,31.53826 5.12311,2.43375 20.15489,7.02978 20.15489,7.02978 -44.17265,-2.42953 -76.03716,11.13432 -94.72864,26.75121 -21.14069,19.55411 -11.78868,42.86201 31.52274,57.21332 25.58149,8.47645 38.26825,12.46292 76.42687,9.02676 22.47583,-1.21144 26.01893,-0.49052 26.24301,1.35373 0.31548,2.59652 -24.96418,9.04641 -31.86578,11.03716 -17.55777,5.06447 -63.5838,15.29078 -63.81419,15.34039 z"
id="path4237"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:2.13333344;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
inkscape:connector-curvature="0" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.29136932;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 741.14237,231.14899 c 5.17911,5.17911 5.17911,13.5761 0,18.75521 -34.5275,34.52749 -90.50738,34.5275 -125.03484,5e-5 -5.17911,-5.17912 -5.17915,-13.57613 -10e-6,-18.75527 5.17911,-5.17911 13.57615,-5.1791 18.75525,1e-5 24.16922,24.16921 63.35516,24.16927 87.5244,2e-5 5.17913,-5.17912 13.57606,-5.17915 18.7552,-2e-5 z"
id="path6-6-2" /><text
xml:space="preserve"
style="font-size:237.56724548px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:#0dbd8b;stroke-width:2.13333344;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;font-family:Hack;-inkscape-font-specification:Hack"
x="1116.8435"
y="417.50568"
id="text3927"
sodipodi:linespacing="125%"
transform="scale(0.97330289,1.0274294)"><tspan
sodipodi:role="line"
id="tspan3929"
x="1116.8435"
y="417.50568"
style="font-size:237.56724548px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;stroke:#0dbd8b;stroke-width:2.13333344;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;font-family:Hack;-inkscape-font-specification:Hack">l</tspan></text>
<text
xml:space="preserve"
style="font-size:138.19949341px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:#0dbd8b;stroke-width:2.13333344;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;font-family:Hack;-inkscape-font-specification:Hack"
x="-359.01129"
y="525.40228"
id="text3931"
sodipodi:linespacing="125%"><tspan
sodipodi:role="line"
id="tspan3933"
x="-359.01129"
y="525.40228">.</tspan></text>
<path
d="m 434.66331,265.76624 c 0,0 -5.87624,-16.97489 -16.80346,-37.37302 -4.42522,-8.26075 -21.55552,-39.48393 -37.31315,-61.56659 0,0 -19.05507,-26.99135 -31.70512,-40.38178 -13.23615,-14.01104 -22.30529,-20.83761 -31.41651,-21.76094 -1.43417,0.82308 -7.40128,1.68002 -7.34029,10.72393 0.15581,23.12199 11.36194,45.22591 29.63861,90.62981 19.61161,50.63174 18.35361,70.8008 15.40574,82.51822 -3.2003,11.39589 -23.90241,15.20102 -56.06034,-14.02326 -16.67759,-14.51651 -63.68239,-79.27534 -63.68239,-79.27534 -1.61653,29.26616 -3.35632,83.29359 -3.93191,94.49265 -0.50478,9.82218 -0.93646,25.37992 -6.97594,31.29347 -5.58886,6.1668 -16.16918,-2.01234 -20.4485,-7.27809 -13.82996,-16.93838 -28.95018,-42.73217 -39.94787,-67.38856 -5.52802,-12.39366 -7.19635,-14.07581 -7.19635,-14.07581 -10.21371,-15.99561 -26.83628,-22.29501 -43.47232,-9.60048 -17.229294,12.37696 -18.862353,39.5161 -5.90882,59.10686 12.19836,18.44856 46.81573,60.68509 46.81573,60.68509 -25.9912,-59.38689 -29.26021,-69.78878 -24.93481,-76.90697 2.55449,-4.20376 9.23472,-2.5327 18.62521,12.25475 10.2233,16.09882 29.00297,50.6202 29.00297,50.6202 16.79982,33.63665 27.327,57.88869 45.8806,58.58235 12.12126,0.45314 16.83063,-13.20078 20.56787,-19.9004 12.4834,-26.89688 12.16354,-57.90459 12.97156,-78.29709 0.30404,-7.67281 -1.9721,-28.83169 -1.9721,-28.83169 20.47806,56.28447 54.31805,88.73667 83.70504,103.69706 35.59166,15.91462 59.63247,-8.12927 54.4544,-69.68472 -3.05843,-36.35714 -4.84572,-54.27229 -29.42634,-99.9488 -13.46673,-27.33625 -14.45442,-32.13071 -12.27766,-33.39096 3.06463,-1.7743 24.54469,26.26536 30.69511,33.79814 15.64679,19.16356 52.86658,71.02164 53.05093,71.28206 z"
id="path4237-0-1"
style="fill:#351d52;fill-opacity:0.45098039;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:2.88823676;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
inkscape:connector-curvature="0" /><path
d="m 76.374471,245.63739 c 0,0 5.87624,16.97489 16.803461,37.37302 4.425216,8.26075 21.555518,39.48394 37.313168,61.56662 0,0 19.05505,26.99133 31.70509,40.38174 13.23617,14.01101 22.30526,20.83764 31.41647,21.76092 1.43424,-0.82309 7.40129,-1.68002 7.34026,-10.72394 -0.15576,-23.12194 -11.36187,-45.22583 -29.63856,-90.62974 -19.61159,-50.63172 -18.35358,-70.8008 -15.40573,-82.51823 3.2003,-11.39589 23.90241,-15.20102 56.06034,14.02327 16.67758,14.51651 63.68232,79.27531 63.68232,79.27531 1.61655,-29.26616 3.35639,-83.29357 3.93193,-94.49265 0.50479,-9.82216 0.9365,-25.37989 6.97597,-31.29345 5.58885,-6.16681 16.16917,2.01233 20.44848,7.27808 13.82999,16.9384 28.95021,42.73217 39.94789,67.38858 5.52802,12.39365 7.19636,14.0758 7.19636,14.0758 10.21372,15.99561 26.83623,22.29498 43.47234,9.60047 17.22922,-12.37696 18.86233,-39.5161 5.90877,-59.10686 -12.19832,-18.44853 -46.81572,-60.68508 -46.81572,-60.68508 25.99117,59.38686 29.26015,69.78874 24.93481,76.90696 -2.55451,4.20376 -9.23472,2.53271 -18.62518,-12.25473 -10.22337,-16.09885 -29.00301,-50.62021 -29.00301,-50.62021 -16.7998,-33.63664 -27.32697,-57.88867 -45.88056,-58.58233 -12.12127,-0.45315 -16.83064,13.20079 -20.56788,19.90042 -12.48341,26.89685 -12.16354,57.90456 -12.97159,78.29705 -0.30402,7.67282 1.97212,28.8317 1.97212,28.8317 -20.47807,-56.28447 -54.31805,-88.73667 -83.70503,-103.69705 -35.59167,-15.91461 -59.63247,8.12927 -54.45439,69.68472 3.05842,36.35714 4.84571,54.2723 29.42632,99.94879 13.46673,27.33625 14.45444,32.1307 12.27766,33.39096 -3.06461,1.7743 -24.54468,-26.26535 -30.69511,-33.79814 C 113.7787,297.75584 76.558885,245.89777 76.374542,245.63733 z"
id="path4237-0-2-0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:2.88823676;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
inkscape:connector-curvature="0" /><image
y="-123.97097"
x="1859.6594"
id="image4081"
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAABHNCSVQICAgIfAhkiAAAIABJREFU eJzdvXmQXNd5H/o7d+99m559xzIASIIAQYgixU0LFTt0qFgynKfYjpO8OHlJlWzHieOXvFTJSdlV SSpxUlHFjhK9smP52Y4p+UVSJEsWaYqLxBUECBDLAIPBYGYwW/f0vtz1nPzRfbtvd9/u6Z7pAYf5 qmb63rOf833f73xnvQT/exBp8+x8b3bvRKzpt9ndze9DSb00ykEiN4aTxudz1ecLBBgnwEL1fcgR d7X6PO5g5iYDDlffVxlwmgGLDDjvFAo3QfhQCsSHSQCaNdnB8HNcndF5Lh4PcNHoOtnaMrhQKEjS 6RSh1MsBQCBASS5HXesdDHIsn+cYIQVGCMc4TqCEcIznx2kicY0BIq0IR5w6hMIpEB86YfgwCEAz wwlwhgBZDlA5wODm5sLc1pbBWZbOU6rwjFmcxxPlFMXkKC1wPnHMH+WHYmExFIPpCXEQgoSjnGUJ hOdNRihhJkXZ5PR8mWTTql7cXszNJznOsHw+nqqqQMtlzSJEoBxXojwvWSl+nCKRp0CAVgTieYoP oTAcVAFo1nauzvQQH48HONNc5m2GK0pIkKSMMMU9MBIUp096Zfm4xEnHeCZOgOPHCUOkMXnWxBVW YxOz/xOmU8rWGOhdk+q3VGpcU63tKwvW+SuUclo6nTNDIdEqFlVreNhjzs9nKHDcchGGAy0IB00A XLT9HAe8ziN+mI+Yy7xpSgKlmiDLvDgZnBkYku77mJ8LPCZB/CjH8bOdk2eO/w63ZuY3+TtdCLGK JjPfUa3y6xkz9drl1Fvv64JX59SsyfO62UYYqKMAB0oQDooANDH+DFfR9rwQjQZ401QFyxJFWebF +4JnD8Wl6Z+SRM8nBcbPMYDrLgsXrW/56cx8pz8DAMYAwtKmZbyetzLfuG689kKyJJXDUt6QJK+5 VAybSCxYwKPWQRWEgyAAzr6dAwI8cI2PRHyCxyOIuh4QR/lYbCL04HN+3vc5gUinAdax3LwsQQn7 IQU9EP0eiF4ZgkcGLwngZRGcKII4U2AAtSxYmlH902GUNOjFMvRCCVq2CC1XAGMOMWGtwkIJUgYt fTtPt//kWvGV97JZqvO8YYyM+Iz5+aJ5EAXhgxQAB+PBAU/zQIqfnubEZLIghsM+aVY6/eCwNPl/ CZz8aQLI7RIS/R74hiLwDUbgiYcgeKTuS8EcD269NaugAKMU5XQepa00CpsplFNZUMuqRWhGCkro zbKW+4Nr+qt/lNeMvKZl9TaC8IHaCB+EADjhngMO80BeiER8gmFwUjjsk07Ij5wNi6NfEDnp427a TjgO/tEY/KMx+IYiELxtZaMzseq/dtM7Vea3+jMwi6K0nUVxaxvplQ0YJdUlAYARa7Ng5r6yWHzj qymjnK0LwohRtREstBqM94zutQA4NL5i3EWjiqgonMRxHvmY7/GPRfn4F0SIj7tF9kSDCM4MITgx CF4Wd18KN613ujdAfbN/axwGhlIyjfTyGvJ3E7BMs9GmYAAF2y6bxf/3pvnu760lV1KCYOlZJaxj s2QCCxY+IDS4VwLQpPVPC/E4BE1bkEwpIB+TT04dChz9osQpP9YckRM4hA+NITw7DCno23tJuoT8 BnenJHQQGACgpoXc2hYSC3eg5vJoTsxidCurp3/zQu6FP9V1aKJI9XQ6YgCzZlO3cE+E4F4IgHNI xwOLQiSSFg2Dk+KC6D0x9OzfCAuRXwNDA3c5gUd4dgTRY5O99emdaA+Q7x6nnRBV/hUSKWxeW0Ap nW0eP8Bi1ltrxuI/v5xffF/QN7WcfFhHAibwAxP3UAj2WwAckH+YR3xcDGoLkiQNyWciDz0Wk8Z/ gzDhmDMCLwmIHB1H5Mg4eEnoTyl2A/ldxmkM63B3/BS2trF5YxGlVLqxXITpRav45cuZt/9jppzI VtBA14EhEzjfPFrYF9pPAbCZzwNP83Nz6+Ldu5YiiiHPE/FP/mJQCP5DBtIwhg9ODGLozGHwcp80 HuiKYW21viF+Y5zW9FvjMOc/BuQ2E1h7/zqMstowbqDMurVZvvMPLm5fuiyKRTWb1XTgSeNeGIj8 fiSKmtaf4QFeiEQKcjpteY4NnBo/G37qKx7e+9fgGIlLAS9GHzuO2PFJcEKfirSjBlf+9QfyGwWC tYkj+32ITo6DMYZyJluLQgiJBsXwZ4e8w4mckLzBmAg9xAElAUCqu/rukvotAFXGg6v092kxFCor uhDzPBb7xCcmPUf+gOf4GuRzPMHAA7MY/egxSAFv/0rRJcP2C/I7IQjhOARiUQQHB1DO5mBqWjVL Ikmc8pcGhfHhtJl6g5Y3rJkZnm1vPwBgad+6gH4KQJOx94oYDJaVYDDqfTz8qS9EhcF/Swj8dmDJ 78H4kw8iOBkHIX3sifoO+Z0RBM0/XSKIKMuIjI+CGgaK2VzNnyfCAyPK+KeIILycLOWLsrzNyuWT bL+EoF8C0MT8H0iBQFQJBGK+R/w//kUfH/hFkPqcfWBsAONPnoTkV/qUPQ4k5Dfm3yqQhBAE4gPw BoPIJ5NglFbduXhIjD7LQf7RRn4l7ffnoKo+ALm+C0G/BIBDjfkJKRCgcswXDjwSfObfKrz35+xA hBDEH5zF4Okj4IQu13C6oS4YxprDuml9g39/IL9tWo44ss+H8OgwSuksDE0DAwMBCQTF0LMRJfjO tpnakmXG9kMI+iEA9swej3hCGg2vKR5uJPJI7OO/I3Oe5+xAnMBj4okHEJoeRj8Rv7MGo3+QDyAQ EBEb8CI+5IPPL4EXOGiqVWFwTwjSmj/Pi4iMjkIt5qEVi3ZIr8IFP+MXotdT5ubyfgjBXllRZ/7Q K2KgFFFCynDkYwNPf1Ug4kftQLwsYeLJk1Ci/g5J9UhdQu5uIV8SeYxOBDAxFcT4ZBBjk0F4vK3z EppqYvl2FtcvJ3H53U2YBm3IvzF9h7tL/gwMjDKsvH8F6fW7dW8CPaOl/u6V4hvfM81yOZ2e1Po1 YbQXAagzH6+IgUBEGeCJ/yPD574scPJfsgOJPgUTT56EFLy3Vn6vkC9JHKZmQpg5FMHUbAhDwz4Q rrfmKRUNvPrCHbz1ympt6bhj/g7GO8vEGMPGzRvYWlqqRmNggJqy7v7su5tvvCbLfDmVGtf7IQS7 FQDHJM8ZMRjcVARB8X588PP/WuGUv24HkgNeTHz8QQieXa7WuVGfIJ/nCMYng5g5FMb0oTDGJgLg emR4O1pdyuL537uCfFbbscwNzK/+cuAYGMP67dtYX7heLxQh2xv67Z+6Xb5xNRzWyvPzlu5YSKLY Be2mxo7p3XFxenpANk3mO+v58V/1ioFfsgMJXhlTnzgN0dcnS78PkB+JKpg5FMbMoQgOHY1Alvdr HgxIJcr4/f90AbmM2lT++jNz6SZs5ttuqwvXydbS7VogE3R1SZ3/yaX0jTsez3g5kYjr1RlD52pi 17QbAahO9DwtRCLLsqbJ3k9NPPdzAT70r1Bdu+clAZOfOA051IfVO6B7yG9qZFGsaPnsoQiOHIsi PtjHbqgL2ljN4yv//h1Qs3nk4C7EHCOs2Y2B4c6VyyS1vuqQdXplvvTq59byhWQ+n1aBcR0475w2 7pp6FYBavz83d0nOZGTP/f6zDw3Js//D3rHDCRzGn3oQ3oFQj0m3oU6QX/VzYz7hgPiAF7xAGuO0 ESLFI4LnAUniIYk8OIFAUQT4/BLCUQVjkwEEgr13Zd//xgJe/4vlriC/bf0YxcLF8ySXStY8dEv7 2mX9tV/O59OlbJaqwKqBXdgDvQiAvW+PB1alQCCiDEcHB894nvk2z3EzlRAEYx+7D4GxgR6SbUNd aH3NqcW/czfRLq0GM8HFbpiaCePZc0cx0AOS5LMa/sMXfwRKqbvWN0F+o3/d3aAWbp1/kxTzmVqQ rJ77x28Vf/iHsqWVHCMDCz0IQC+dYBX6eTEUEuVg0O95OPBjvyU6hnuxE9OIHB7tIck21AXDujH0 XJnc4t+B+U3pZ9Mqbl5L4pEnJ7quiqwIuLOQRiap1h07QH5DIRxuPDj4o3GkN+8Si1b2Ikqc+HiA k1/cKG4lQ6EgLZXOUuBqT11At9NxVe0/zEejiihJAflB6ZlzMuTP2AE88RAG7p/qJe9WqoEXa8+w nTZtuKKBg7sNwlV3Zw1O7REkuItuYGI63JA/B465Mt9ZCJf8FVnG5LEHa4UmIN4Baew/jSvDEU1b kIBFAbVZ2e7QvRsEcEB/UoxEPMqU9PDcuH/8dwEiAgAvi5h8+kHw4h42cHQB+aw5rJvWN/jvDfKd cbxeAR99agLPnpsD3+M0dj6j4fp7CQDdQ36jf71NFK8XpqGTUq6ynExAYn5pwLdlZl6R5TRV1QcZ sNT1iKAbjlUNvwA/PT0i5nIFZXrw8K8zRrxAZVV/+Ozc3sb6fYf8dhrcGscN8v1BCeGIgnDUg/HJ AKYOhTE44tv1qiXhnIzvwHy3Mjvc7eYZmz3Oipk0KRUqW81kIv78Id+hr1/ZfvfteBxWInGOAs+7 qVEL7SQAtdO3c3OXxNVVXX588DM/JnLiJ+wAodnR3Rt9OzKM1V930WBuWk8I4PNLCIUVhCJKldFy 9bfiJvRzoQpAuWh2bYM0+rvXnycEU8dPsWvvvELAGBghfEwY/BeiSD+n64tm0+TQngWAA17n19ch DkXGglFP7J/XCiKJiD8ws0MSbahPkC8rArweAbIiQJZ5KAoPRam+Kzz8fgnBkAx/QEIgKMEXkPo2 49ct3bmZcXASjvLvjGCsxb/y4vX4MDg+g82VRQAMPOEffiD4xLnz5Vf+YG7OY87Pn7OqKEDQQQg6 CYDD8IPIcaJ8yvuJX+TATdkBBk/O7m5/fkcNRgvkK4qAyakgBoe9GBjwIhrzVJjpE3vuj+815dIq Ft5Pdt9NVd1bUa8VQUYmjrB0Yo3oahkAEJQi//ekOvW91bWNdWDRRoG9CkCIN4yieDR2YlIR/L9g eyqxAEKzwx2iu1APkM9zBPefjOPM2RGMjvl7Xpg5CMQYw3efv8lM3V4hrP3r2E210/rmNhN4DmPT J9jt6+crQwpGB8a8c19IWPlfF8WskUqdM3dCgXYC4ND+rMCpIWlKOf73aufzCMHwQ3PoaWG/B8gf GfHjc//HMUQifdwxdI+JWgzf/uN5Nn+xYv33aui1vLRBkNjAEJLhGHKZyiyhLMg/MyQHf3s5V1wF Fu3VwrYC0A4/HdoviOODQ0Me3vPTtmdwIt7b2n6t8jswnwEzs2H8rV84+aFm/p2FNL7yb95mF3+4 Vqlnh7G93Sa1YHC42+Fa0KCxHcenjzPbXieM+Ualmb9tWUSKRrNC5QhexcutrG4I4LT8+ZUVTjqk nPo7cJzciR7rciasS8vcrpcgcnjus0cOfL/uJGoxlEsGttYKWFnMsesXt7C5Uqh47tHQ63bk4/MF EAzHkMskwBggEO/PjwYHvrxVuq0Br/PoMCJo1wXULP+x+GxY4b0/Y0f1j0ShRALtW6SpcL2M7Q8d iuxqpq0fpGsWsmkVhZyOfFar/OU0FHMGDN2CYVgoFw2YJoVpUJSLBgyNgpmOCnbJsL1Afqt/5WF4 7BDLphMEADhCQlPK4Z/NWqn/ODdnGtURgWtX0CwA9WtZ4gneKt0Rj8uP/RwYCdsBoiem0JF2LHDl X6uVC4TC+898Q7ewtpJHcquExEYRyY0iEpsl5Dut27sg2E4reO3Saj+30UHrW9JvigMgFIzBHwij kM8AYJB47//Jcfx/3djIa0DCQKW7t9BEbbqABDftywi6yEle0XPOztQTD3Ve5u3B0HOT8HSq3D7t XVK5ZGD5dhbLi1ksL2awvpIHpc3lc2/8tuv2e5zO3Svkt2vf4bEjbOH6WxUUABk8Kp765IXij74B XONRYX63CJDndL0kHPc9+SDPhMO2Z+RQh5W+XUB+c+UXFzLI53QEgns/G7i1XsTLf76EG1e2YVlW G4FszB8Nr22YzwjbC8P6BflON6vqHooMQJY90LQyGABFln8yEuG/I4o+IZ0+bAILLd2A09pyGH8F PpMJiGEy+rlaQIGD323Klzkr71Yh1pn5rO5uGRTf/dZCax490g9fWsZ//ffnce1SApbZjvmsJf+2 kM92v4LXDyu/I/OdCTOCyMAIqpfaQCLyp0Q2FLMsna/cqdg6Emg2twmQ4DY2ysJg0JJlUfnLtkdg PN56cLMdJDkK7JSPun/7Brv+fhLvvrXeXM6u6dI7G3jxfy7Csmh7DWsD3wyssk+vSSBaIL8TfLso hHv9W/NvJ5ANylX1t1BlflP+sYGJeizGpElh9lmPJypUruJpuJepWrc6VT2u8ZYlCUcCTz9NGDdo e4amm2b9nJVso2FOgW5piQ4N9t1v3cLqnRx2QzeubsOtwXbK3w3ya1q/K4ZVn1o0uFP9m9OHa/va kO+Wv6L44fUFaw4eQfmMKBIxElnmgcMt4+umSYJz3NxcmKNKSAhxvpr2C14Z3sHGTQ2dC7ybTRv1 R8ug+O+/fwVbG0X0SlOzobYC2S7/dpDfGUE6M8yp6PX84ZJ/U1otAtOYfiPkN+dfafvYwHgtQcL4 jyg0NFjpBsbt09s1akKAC0TTPHxYyggCLz9iewRGByrTvm0Y5iywUz7q/js3WHOFSgUdX/0v7/Us BGceHcOhuahL/s1lqjDeFfLZDpDf7OZofLtK3eTfWSEa828H+W4KGYkM15wZY9ywOPYopQpfHQ0A ji7A6cABXsGyOPlo+MxsTBj+FTuH2IlJyAFfQyaNBUZnrW8ucK2SbunUHQyd4uqlBCamgwiFu5sa JhzB/aeHoOsWVpdybcvcbmxP3OrnVo8Gf9YSzOnefZs44jjcXRnfIS2eF7G9vUos0wDAwDE+s8zu vmhMyAa2P0GBq7VDJLYAVDd8TguRCC8fVR56TublZxiqO35OH62c5u2p8tV/HeK0rbzjx9ApLl/Y QjAkY3isu/UHwhEcOhbF+HQIGyt5lApGY7pdHMhoqUdD+R2erDVYZ4Fpp0TuCtFW65vjNLmXSjlS KlW2jfE8fFm28FUha+mqumkBqRpuOCzDc/zc3JqYKHqU44G5v8cR8TgAyGE/YnPjbaW1N62HO/Nd 4jDHP2YxzF9JopDXMXM4DJ7vbq0gOuDBmY+NIRCUsblWhKoarg3Wy+5cV+Z3Vb8OabUZ27ecL2yX lkv+zKJIp9dJxZtESgx/lCtvZjXNtIBcbV3AFgAO0HlKLTGgaJ5Z5aFfJ4QLAEBoYhD+4ahLhfp7 pVpDkDYNtr6Sx9X3EhibDCLY5bQxIQSjk0GcfWIMgZCCxEYRaskE8CGGfNf2bcxfFCRsbN4mVS4R kXFX1ktbV3S9bALF2h23DgQ4yUtSQh73Hx0ZlGZ+1U4pdmwKsvP+nrabFjoUro+7c8tFAxff2kB6 u4yp2TBEqbujDRxXEYRHnhrH6FQQ6YTKChnn4U1nXjtDbmv9e2WYu0LsCvLdujReQCp1l5imbgdZ u1u6/ZIRPmygtFQ7RuZAgJgQizFpRnjoIa8Y+mk7s8H7Z8BLYi2jtlrvWmD3wu0E+d002OZaAZfe 3oCsCBga7X7HECEEsUEvTj82SoYn/CSb1pBL73yKt4H5XdWvQ1r7APmt+TPkC0lSVitL0xxopqis /Q9Byxrl8ict2xDkUTtIQAWvNyyPK8c/qfDKp8AqN1oNPngYpDp93Jb5eygwaxenoULuCKJrJm5c SeLS2xsQJR7DY/6ut24TAgwM+XD6sVEyeyxCSiUD25vFjvm31r+D1jfEd4njcO8H5LvlXyrlSL6Q QoV3BJvGrf9mGKzBEKwjQPys4GUFedZ77LMikc4AlZu8oofHGvjTtpIN/u4M2w3kd4MgarkiCNff S8AfkhEb9PW0Wy0UVXD/w0PkyP0DRC2b2F4vtTR+A/N3xbBWhbBq1WtX/94UojF/Al3XkM5WDEFw XGCbpX5HVQuqpk2Z9uGRqgCc4+cmbgmMUWVKeeBvEQizAIN3IITgxFAPEt6uwK1xGpjfE4K0z79Q 0HHl/CauvZeA4hEQH+7tMEcgLOPEmSFy4uFBYhgWEncL9sSmI/92AtmhLh1W8Nzr15RWTwpRMel4 BlBqYWt72T4ezZmm9Y20WtzS9Q3THglUBeA+jtJVyTCgHA6c/ocEiAKVGUDfYLQpw14Z1lhg1i5O Q4W6QxDns3Nip5jTce1iAu+/tcV4AWRw1A+O714QvH4Jcw/GyclHRwilwNZKobqHoDPkftCQb39a ga++ckTA2tZCbQqXUu41VVqbz+ejJrBuoSoAHKDzw8MBkRBNnlJO/hoBkQAgNDkETyS4A+S5V9Kt wKzx3y4lvDH/Tps21JKBhfe38fZLq9jeKkGUeRKOebruHhSviCMPDJCHnhojsiJg406ufgmUs1wH BPJ5AFzVnzACnuOxnrhFKKscTbeo9W7S3D5fLK4b9lCwKgAnecbWRI8n5p+STvwjVOeKw9Mj9Sng ngrcWEmGJvfdQP4OWu9kfPPY3jIpNlcKuPzmBi6/uQ61bMLjE4mvy40nksxj6miEnH5yjIiygM3l fFUQDhbkAxXGk9oOYWAjeYeYllEtA714J3vzh0Y0qqO4SQGw2o4gxigZwHSkkmIlNU4U2ldypwK3 aP1eKtmaf7vpXJ4niAx64fWK8PhEeP0ivD4Jsqc6TqyGvfl+EhsreRy+LwZvoDtB8PolPPXcLPno M5N4+y+W8eafL7NiTt+xfruD/B0UAgBYRevtV8LqjK+6QBQkqHoRDAw8WMTr9RIvjZIEzgA4DwEA ARYIY34Skj1+OzUGVI5774FhDZDvWklHWm0r2RjHqfXBiIKJ6RAGR3yIj/gwOOon0QHPvp8ikj0C Hn92Fo88M0XefXkVr//ZHZbbduxn3I3WO9276lIdWo/6Si2p+tlCIPACajsxCednTCGx2DpJJCrr Kg17Ai2TyBDredYs6J6ksoPWd1XJ9nEkhcf04TBmj8UwOxfBwFCfLqHaJYkSj0eemcLDH58gL/3p Anv9O0tgtFLYe2Xo1bS+FqTOfMIAQuozpYRjLQc5GwRAEgWxni8D4bguGLa/kE8IwczRME6eHcHx U/Gup373m9KJEhavbGN1IcuWrqaQTdYRoF/TufVnVmNuJ8h3RYLaNzkYGCMtfV2DAFBmh2aNofZp Orelko6fcNSDR54axwMPD8Hr7+MXRHZBlDKs38nhznwaKzczbPl6GuWCXg+wG8jvSSGatL4D5BNH eMLsnR8VT4603gjTIADEMA04MYDRNhVqLPDeIL8x/YEhLx7/1DTuf3jonp/jrxeJYf1OHotXk1ie z7CVGxloZdMZwPFc+TkokA97DFB1o4zWkqQUDqmtUIMAlJlVC8AYwExrxwKzxn+7hvxASMYznzmM +x4a7O8HJLqkfEbDrStJ3L6aZovvJ1HMtWp4Ow0+GJDv7scYrWdJTFcBYMBhRsgCI0QvVcpWiUIt 2pZhjU67h3yOEHzk6Qk8/eMzkPbx6tZmMnQLd26kcftamt26nMTWaqG1zPViumu90/2AQH7lt/5u WjZyMTBwZUJUlkqNMCAPwIEAhHDsLjYzE+xErWyWZm+l2h/IHxzx4bN/4z4MjvbxGvkOpJZM3Li0 hfmLSbbwXhKG7kS43hDsQEG+I61mJDAtrZY2Y1a6VCqxoj/FgEsAHAIQiURZNrtaZB6mERAZACzd 2DfIf/CREfzlnzq671Y9pQy3rmzjwqtr7OZ7W7AsN+Z0j2CWM44z7L5DfmPf3gL5jk8sOwXDMDVH ZjTj9wdYcbOec7ULWGXZLGWEcBTANoBRgMEoN56Y7QfkE0LwE39tDqcf7cONoh3IMinefe0uXv/u MsskS61l3QXDDgTkV9/rz+4zgIQBlFnQqb3ZhcEwjWSxWGJAqBayigCnGSEXmNfrYybMOyKEUQZA K5b6CvmEEHzmrx/HybM93i3UA1HK8N7ra3j1W0sss13uAnI7lNnhfs8hv4mZdbdOkN/oV9IqG1xs m85k6i1CBAps1mpdFYBFFo/zbGVFp9Rj3Ga88CgA6IVSF1rfTSUrP48/M7WvzF9byuGbv3uVba0V dgfTbSG/nQZ3SKtbyN8Vo9tDvvO9qOedayYsLWwuElJgwGEGrNa6AADnMT9/kvp8eWpSc1GqdkRa odiZ+T1M545OBPH0j+/yTsEdyLIYXvuzJfbqtxbv7bp9D2l1o/WVZ2C3kN8cv6Tm6+UgLFEyU7lo dIDmcqsNXQCr/ImMEJ6WoN3yVr/vaBkmTF2DIEq7qCQa/D/92cP7skijlk38ye9cYkvXUh3z70Xr gQ9ubN8ZCXpDiZKerS3mWLBuc5yf5nIFCpxmwAIDGs4GbjKOU60sSzQc0NfyJZcKORqmE/Or/oOj PkzOhtFvKmQ1/P6/O19lvqMwTfl3ZJgL5Ft2Wi31a0qrwb+NQjjTsiGfVQy91v6bNGl543s75hPm 3kUU9VytMJZlLamqTuNxngGLtRLWVwqg0Gh0gKZS76+A0GLFkaGUyjQ1mJuGtW8wBoaj9/fhAxJN ZFkMz3/5Mtu4k99BILtnWDcnbztCfluFaN20URGC7plZ92sNb78TVsmLMAKLmsir9Q9PU7DrPC9a 8/MZCpyvld4hAKdZLlegZoAalmWdt9eQi8l02wZDhwZznrwdHOn/RM/L31xkKzcyOzAMLgxjrgzr 9uRto387hXDmX4F8Vyu/hgROP9LE6B5Ropp1upwErU4DMwAlI/t6qaRbgGjvaWNARQCq0Z9nKX6c plK6VYL2uh2klMqAWo4ZM1cJb2yw5n16/V7UKeZ1vPn9O23zPxiQ79D6GqNbmVlHglat3xHyWRuU AJAqbhBHFVKL5cXrHFeilRFAvXYOBABDIk9DIdEq0tSP7Iak1IKayfUE+c0NVvt+Xp/o2vnNyjSu q0B2z7D9hXx0Dfmoam5byG+j9fY6gJtgbZc3awUzif5mIEANnpcsIN6qUNPLAAAUkElEQVRwYaTz mC0DArRYVK3lwnuXwFjOrlJhO10L0Q3kO/05cGx1MesMvWfKZ3XmzjC4MIy1MKyXyxYa/Vnb+u8V 8sFsrXeBfNT9635o8kMtLZOZyJW3a4U0TfONVEq3Uqm81fwhiSYBiFOelyxLLOsGMd62Q+YTyXol XRpsp/t15t9LolRoWYncNcWGvPV9DjsyrNGt0/06tYcuEWTXkN+Gmfa7K+SjM+Q7USJZ2AB1fEg0 z3KvcZxsVvt/Z60augAAz7NUKm/pumyWdPUl26uUyUIrlbqG/Ob7dSyD4uVvL/UNBU48PISJI+F6 hl0y7MBAfoOh1ywM7Q29TpDvRIm7+VsOo4suJ9mNm7GYYgFKy33BTQgABoi0XE6Z68bFbzLQWued ubveELLXK9XOv3wXi9e20Q8SBA4/+ysPkUeemQLnunG1Mf8PDvK7Gdu7z+W3Qwk3yK+FB4Fhatgq rtYKqlv6Nw1DMWS5bFUmgBoFwLkWW23JQc7r5XhLYtaoMHWaI8IhANBVFbHJCRCQVq3HTpctVB5u XtrG0QdixNflPvxOxPMcDj8wQI49PETUkont9WJtR64z3w/qQIYrhMNd6+0Vf1eUQJvwtjCgUbCW 84vYLK4Q20jKYfufbRXSa2trfh14wURTF9C8GE+AFFFViXg8Pj4sDBKF9z7LwGCZJgKxGETZcTNH O61v8K+7mwbF1Xe2MH0sQgJ9uhjaF5Rw/OEhcvqpcRKISMTUKXLbFeDalwMZVeY0H8Oq/PbOzAYI RyMz7fdWwXIgQZNgXdl+i5TNYrXI9L35/KUvRSJeLZPJ60CqpQtw241BgLNEENa5vFVeH1Omfh6E KBUPhmA83tA4vd6vY+gU77+5AX9YJsMTXVw73yXJioCJwxGcenKMfOTTk2RyLkxiwz5IMg9mMpRL 5u603uluM99mTruJmmq07piJ2nuvKFEzDKtlKxl5XN0+XzOQNaZ/+W5+5c1EYlgDiAmst3xi3u22 cArE6fDwurmyohfViPodBZ7PAwyZtQ0Mzh6CKMt7ui7d0Cx88/eussWrKXz63BHiD/X3mniPT8TR U4M4emqwZgzpqoXkegG5lIrctopS3mC57TJKBQO6akIrGCgXDOhlE9Ri0FUThulAy11s1ao/t/bn 7VCi5ttund9FUOzu4EbmErG7Z0KovmXc+QbPG0blM3KrDRyql6KVqlfGnZECgaLnvtipB8blmRcZ YxwDEJ+awtjRub5dqaZ4BDz5V2bIw09PQBAP7pdCGAPUUmWPJGGVjSf2VnGtZILRimBbBoWuWihl dZSyOgopnWVWS9heKyK/paIzo+vvNc12EyyX7qRsFvH95a8TRisobzHz+bdL87+k0GwxlTqtAs+3 9P/V1FqIAOCAw0IkQhWfL+B/SPnkVwRO+jEA4HgOxx5/gkmitKt+lTX411+CYQUfe3aanPrY6IE5 /dNvKqQ0rF5J4/a72+zma1vQSpYrM5s1u+7WHgkuJd8ki9lrABgICE2oW88k+VuX1tYCJeC8jvpn YxqoXUsTYIaoqkAoLXF+yb8WECKfBxhhjIHjORIIO6+O6wz5zcGc7rabpppYeC+Jd3+wArVsIRL3 EMW7i28SHmCSPAIGpvw48tFBcuavTpHwiELWrmZhqqwLQ8/FlqgKj27pOJ94lTBWWbMxYXxnjSz+ XqmkqqqqG27Gn00dVG0dwHEyOkq5jJXcHhanz9ofjdTyeUTGx8ETHi3M3wHyOwsMg6FRLN9I480X lrF0PQ1qURKOe/63QwWOJxg6FMT4/RFy5c/X20I+aQP5zuHg9fS7JFneAFBp9ZSW+uWN3MZKLufT AK9t/PUkANX8YsjIa4TmCRf2RDZ8XPCnAcCiFqhpkNBAfNeQ30lg7NdssowbF7bwxneXsHwjDbVs kEBYgezZw1fKDxgFBhRce3EDWt5oC/n1cX8rSpSMHM4nXiGsMjsDC8ZL62TxP1uWVlbVYQN43b4T 0JV2UKt1oHiW+HwZUuQzW8P81GlCuBkAKBVyCETjkOx5gR4hvyWQm41qp8WA9GYZC+8l8cZ37+Dm xQQKeZ14fCJ8H9BXxvpFxbSOd/90BZZKd4B8wA0l3t76ASkYOTAABMzKYPsXC1ZiOZHI64DiOvRz UicBqArjEtSQRPyEJwKTLgX5yOdBiAgwlAs5MjBsf0Nwd5Df7WSMfb8OA0MureH2lRTefmEF7764 go07eRSyKmEUUD4E3xO2yTIovvHrl1l6uTJx0y3k236rhSXczF4itsqZVP9v6+z6H5ZKpqqqig5c qd0I2q4MO+3UqI0IolHIHBfynY08+k8V4vmCHWB87j4WHxnrC+S3i9PLdC4PguiQF6EhD4JhGcGY B6LMEUnmwfEcZA9fG76ZOoWpUxiqxcoFA2rBQClX+dWLJkyDwihb0MsmmMlqzcVzBLExH577RyfJ 4PTuJrOym2V8+zeusM0blS+j9DoCoJTi+3e/RopmAQADYyy5TK99IpEtruXzvjJw3qg23Z4EAHDM CwSDm8qoND0wF/nI9znCTzIG8IKAuYcfY7Ki9A3yne77eSBjt7tzx46G8dCzE+S+p0Z6PslsaBYu fmsVb//RHaYVzJbhnLMsNSRwEYYLyR+RxfzVWroFWvzHS+r8/xeNmsX5+ZMa8Lz99fC2zAc6fz3c JlZJaNYcGSkYK6t3s3FP5osxJfa7AINlGli6epHMnfoIA+H2R+v3mFb92Y7T3f06FbdK04/MBDH3 +BA58fgIwkMe9EpqwcCVP1/DO19bZaXqWkWniZ0WJHAIylpxBbfz1xxVs84vFC4+T6mpzc9Ts/qV 0I6aX6tfl+WvosDTwvR0RjFN5jslP/ElgVN+0g4wNDmD0ekjDguwVrq6w73S+pa86loPdDed6/GK mDk9gJlTMXLo9AACsd19zHrtWhbvf3eNXX9pE5ZWt8c6M7qxLDXmM6BsFfH9u18nBtXtUKU023ru ZmrhUsGXL2HzSaPdrJ8bdSsApPrHA2fEQKDoiUYHB09JZ79FQA6xaqkP3f8wC4Vj9Vg9GnrAB3cg gyMchqeDmD0zgNlTMTI+F+npdtFalpRh9f0Mbr2ZZDdf20J+s3H6t5PWd4L8CjspXtr4nySlbdXy s6HfsrRSKgUNWLA/Gb8j84HuugA4EqPArDk6eknPZDLbSax+ISaN/f8ERAYD7sxfInOnHmOyJNej 7QbyexCYvZy8HRj1YebBAUzdFyXT90fh2eU+hexmGXcuprByOc3uvJ1COWd0NObqjG7u/zv5ARdT bzUw32Dmn6yzW3+s63ktm6UGsLqj0ddMvYp4rSuIRJZljZe9j4ae+Dt+LvAv7TwVrw9HTj7CREHs mmH36uTtwIgfkyeimDwRITMPxBCI7g7WM+slrF7NYO16ji2/m0JmveTK6G5W8HaCfDvNW7nruJB6 zcEv69bdwvxPrOvFRD6fVoFxHTi/7wJgdwUcMC6GQpwSCES8Jz1P/GcR0k9Uyw5fMIwj951lvPOa uXsM+QLHYWgygKn7ohifi5Cp41H4d7EJpZw3sLmYw9ZiHus3cmz1cgaldLX/bepO3Ji5F8i3BWS1 uIQ3ki/WoIswFLfZxmdvpW9fGBvj1V6s/mbqvZOrCcEZHliVAoGIEg77wqfkJ75KCPeonX8wEsfs 8YcYD3JPIF+WOYwdCWNyLobxIyEycSwKxdv9lLFaNJBeLyG9XkJyuci2buexdSuPfFLripndjd97 6Q4q4ZPaBl7d/A6xmH04h5kFs/h3l4wbf1bp98d14Ad2v99x1s+NdiMAdjwOOMfH4wlJFNeUGDc8 MqWc+hpH+KM2Y2LDY5ieva/eXH2E/HBMweSxKMYOh8jEXAQj06GORptaMCqbQRIqcikVpbTOMptl pFaLSK+XUMrqOzBz90u1zvjdQj5hBBk9iR9sfrtm8QNgBVr81dvlO39oWelSOj2pOZjfE/TbtFsB AGqfmjkszM3xUiYje8aEqdkxz+zXCbhRoLI4ER0YxuzhkwyE2/P9OgJHMDgRwNGHhxCIyA1SRS0G rWTCKFusXDSgFS2UchoKCQ3ZpApTq39FvJfLFnpl5m5RohkJEtoGfrj1PSfzUbbK/3oVN7+Uz6dL 2SxVgVUTXcz2daK9CIDDHnhaiEZXJU2zPMfCD5wY8c5+jTDUvjQRCEUxe+whxtn31vYB8rux8t32 3aENMzvNAHbPzL2hhB3+bmkZbyZfJBarX05pUOMPl9i1f1Jhflzrdqp3J+rTIvsSK5dPskCgwDaL G5mgEvyhlwQ+DcJ8AKBrZRQySRKODYNzXF7cbyvfObHTjvm72Z1bN9TahG+DEu2sfLcNnXbqtws3 8db2S4TafT4AE8ZX1/I3/5+0mijlcroOnDKrX/3aE/OBvgkA4BSCtdzqtkf0vOQRAp/kQEIAoBsq suktEgwNQODt8XZnyG9wqz3bcdpvze7ETPu91925XTOzqSwNVn5TfKdgMcrwfvYdcjn9JnEcfGRl U/ut68Xzv1mwssVcztCAJ43dWvxu1OdtNnUhKKCQMQzjeyE58jhA4gBgmjq2E3eJrHjhqX+aYBeQ /0EdyCAukO+CEg1pN6FEU3wCQLNU/Cj5fXKneLNWdQJCNVr+4gpu/E65nC/vB/OBvgsAYAvB+HiG rmxvFyhT/ywsDXyEEG4UABizkNneILqhkWAwBuI8nugqEE6/D/pARiNk1/1cUKKp/3eDfABIltfx 8ta3Sdao3+ZBCNGyNPdL67jyx/l8vlyB/f4zH9gXAQCAJba9HWEzMzzLGXk1icK3ohiI8IQ7abdB qZhDLrNF/MEoRN6x+bOtDXBwD2Q447dCvjtKUEZxNXsB76RfISY1alWmBMs5mvmbK+qtv5Bluby5 6deBoAG8YI/z+8b8Ss36T47RwWEeGBeDwQUpGIx5JskDz4Xl6L8ijNmfIgMhHOKD4xgdPcZ4zjlK 2NnQgwsz0YGZdvHarfO3hu/vdK6d9nZ5A++kXyU5I9PQcBboiyvlhV8pkfKGrhe0dFrX+zHU60T7 vNU2xYCzTNMMGo0yulG+u8Bbxvd8QvgMIWQIqMwWFIsZbG/fJZLsgUf2oztDz0YCONxIk9buAiV6 MPR6hXyDariYep1cSL9ONKrWWokAlk7Lv7VovvPPLE5Lh0KWuro6rgOX9jTJ0w3tBwI0p80BZzhg U4hGFVHXIUcioeBx8aF/KnDKz4PRhvnaSHgQoyPHmEfx7/m6dNfwNkq0tQ3Q4/i9fXdgp0WZiVu5 67iWe5fotPG6HEas61k9+2vz+sbbopFQs0pYx2bJrBzn2l/mV0u/79TQJcTj46KmLUimNCTPeUdO DApjv8kT4ZFKUHsARBAODmBseI4FPeF6Qdsws+7XKzP3dzqXWhZuFeYxn3uPqFaxoVEYWFk19d9e K1z77ZRVzosi1SuQ/6hVNfYY9pn5lRrcGyL1v3M8sChEImnRMDhJFKnn/tATPxPg/P+EEVbhdtUG ICAIBQcxPniEBX2RJiRo7Q7aocS9ns41LAO3CtdxM/teA9TbZDHre1vq5r9YppklQd/UZPmwnkjA 3Ou8/m7oXgmAM68qGjwtIA4hpC+KkhSQQ2JsZFqe+2WB8D9NQGoL9TYmeJUg4pFJNhgegyJU9uTt z3RuY/xuUQKUYUNdw0rhJrlbugOL1S17R13ez9HSv9tit75PaVlTVaqnUqpR1XqnlX9PmA/cWwFw 5ulAg9f5uTmfsLZGJFn2SQNibHREGv37Hk75PCXwNkZlIIQg5B/EUHiKDQSGIJDKENK9/99L374z SjAAOS2N5eICWSksQLXKVc418o8S60JJL3/pLi69YFk+zePh9KXyuoHNQHNf3xp5n+mDEABnvlU0 OMcBi0I0mhUMQxBl2SeFpcHhSWnwF0Te83OEoeGLpqg+ERD4PRFEvHGE/UMs6omBI3zH4eBeIb9o 5LFVXkeivEaS5XVotP69wOYSWsx6q0xLX7pLL79sWT5NVTNGVpo1UIH7ZiPvnjLepg9KAJz5O/4O 80CItwVBUXgxKnpCQ+LcTyiC97Mc4z/KwOrbjGo/lQeO8Ah6IvDLIQTkKPPKAXgFHxTB0zBk7GYF z2QmVL2AnJFHQc8gp22TtJ5AyXRcoW9HcRaGsE2dGd8q0NTz22zrSiZT1AcG/MbSUtgEYFYuany+ eVLnA2E+8MELgE1NglBBhLm5Ar+xURYUJSyKIhHj/MBkiBv/nMCkv8oTcgioM79OrYzhCAeJVyAK CmROAk8qI0+Bk0CZCcoYGLOgWxo0qsOwytAtrSFN1pBoYx4MtMyY8UJB17++jdsvb5m8FpbyRrls Gum03wSi1kFjvE0HRQBschGEBIc4hIi5zJumJFCqCbLMi1PS8UMeIfiIIooPC0R8CgyDbt1EIzXe auMe0iVOg1flAgYG67oG40eapr+aMu++YXiyhXTaMnheN4eHPeb8vN8CshYQsm/nPlCMt+mgCYBN thAA9Z1HHBDi4/EAZ1aFweuVeEnShHyeE+dCR++T4PuYyIn38+COMsIfIszxsWTWjBVtILwhRMWN AglQOm8yY94w1bdydPVHm0Ymw/OyWS5rFs/rJs9L1atYjzu1vbl/PzCMt+mgCoBNTmPRgQoXCKBy QJSLRku8Zek8pV7O65V4yxJ5SrO8zxeS4+bAKA/vpCx6xsFYnIcQ4TiEwEjl/npC/ABUMNNkBAYF y4LStAlkDKhrFrFWMjR7R6V3MzwvWKmUbhHCU45TLZ6XrMFBkVY0PUCrTG+n6QeO8TYddAGwibj8 2gJBgAQHLBAMGdxcOMxtbRkcpSZHqZcLBCjJ5ShhzOK8Xi8pFinxer3umRCVlUol5vcHWLFYYoQI lONKNBKJslyuQFP8OEUiTysMX2WVmzdbzuEdeKY76cMiAE4iLs9NAnGBAOMEWCDAEAFWCTAExKNk Lrrets6p1AhLJFIM2AQwziqfV7O/sHWaVT61ct7J4A8l0530YRSAZnITCPvZxe9M9XW2qe6LDDhv vzgZ6cbcds8fOvpfMzvdLlvhNv4AAAAASUVORK5CYII= "
height="1365.3334"
width="1365.3334" /></svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xml:space="preserve"
id="svg4768"
viewBox="0.171 0.201 512 512"
height="48"
width="48"
version="1.0"
inkscape:version="0.48.4 r9939"
sodipodi:docname="logo.svg"
inkscape:export-filename="/home/me/src/emacs/ement.el/images/logo64.png"
inkscape:export-xdpi="120"
inkscape:export-ydpi="120"><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1173"
id="namedview72"
showgrid="false"
inkscape:zoom="6.9532167"
inkscape:cx="63.22113"
inkscape:cy="9.8428958"
inkscape:window-x="1920"
inkscape:window-y="6"
inkscape:window-maximized="1"
inkscape:current-layer="svg4768" /><metadata
id="metadata70"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><!-- Gnu Emacs Icon
Copyright (C) 2008-2017 Free Software Foundation, Inc.
Author: Nicolas Petton <nicolas@petton.fr>
This file is part of GNU Emacs.
GNU Emacs is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
GNU Emacs is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with GNU Emacs. If not, see <http://www.gnu.org/licenses/>.
--><!-- Created with Inkscape (http://www.inkscape.org/) --><defs
id="defs4770"><linearGradient
id="linearGradient3961"><stop
id="stop3963"
offset="0"
style="stop-color:#ffffff;stop-opacity:1;" /><stop
id="stop3965"
offset="1"
style="stop-color:#0dbd8b;stop-opacity:1;" /></linearGradient><linearGradient
id="linearGradient3874"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1;"
id="stop3876" /><stop
id="stop3882"
style="stop-color:#000000;stop-opacity:1;"
offset="1" /></linearGradient><linearGradient
id="linearGradient3866"><stop
style="stop-color:#00ff18;stop-opacity:1;"
offset="0"
id="stop3868" /><stop
style="stop-color:#000000;stop-opacity:1;"
offset="1"
id="stop3870" /></linearGradient><linearGradient
id="linearGradient3817"><stop
id="stop3819"
style="stop-color:#00ff0a;stop-opacity:1;"
offset="0" /><stop
id="stop3823"
style="stop-color:#000000;stop-opacity:0.99215686;"
offset="1" /></linearGradient><linearGradient
id="linearGradient4292"><stop
id="stop4294"
offset="0"
style="stop-color:#411f5d;stop-opacity:1" /><stop
id="stop4296"
offset="1"
style="stop-color:#5b2a85;stop-opacity:1" /></linearGradient><linearGradient
id="linearGradient4284"><stop
offset="0"
style="stop-color:#8381c5;stop-opacity:1"
id="stop4286" /><stop
id="stop4290"
style="stop-color:#7e55b3;stop-opacity:0.99607843"
offset="0.56639391" /><stop
offset="1"
style="stop-color:#a52ecb;stop-opacity:0.99215686"
id="stop4288" /></linearGradient><linearGradient
id="linearGradient4898"><stop
id="stop4278"
style="stop-color:#bab8db;stop-opacity:1"
offset="0" /><stop
id="stop4280"
style="stop-color:#5955a9;stop-opacity:0.99159664"
offset="1" /></linearGradient><linearGradient
id="linearGradient3294"><stop
offset="0"
style="stop-color:#6376e6;stop-opacity:1"
id="stop3296" /><stop
offset="0.50094414"
style="stop-color:#222989;stop-opacity:1"
id="stop3302" /><stop
offset="1"
style="stop-color:#00003d;stop-opacity:1"
id="stop3298" /></linearGradient><linearGradient
id="linearGradient3284"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop3286" /><stop
offset="0.84845906"
style="stop-color:#000000;stop-opacity:0.49803922"
id="stop3292" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop3288" /></linearGradient><linearGradient
id="linearGradient3274"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop3276" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop3278" /></linearGradient><linearGradient
id="linearGradient3262"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop3264" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop3266" /></linearGradient><linearGradient
id="linearGradient3242"><stop
offset="0"
style="stop-color:#282828;stop-opacity:1"
id="stop3244" /><stop
offset="0.39253417"
style="stop-color:#808080;stop-opacity:1"
id="stop3252" /><stop
offset="1"
style="stop-color:#d9d9d9;stop-opacity:1"
id="stop3246" /></linearGradient><linearGradient
id="linearGradient3202"><stop
offset="0"
style="stop-color:#2b2b2b;stop-opacity:1"
id="stop3204" /><stop
offset="0.5"
style="stop-color:#828383;stop-opacity:1"
id="stop3250" /><stop
offset="1"
style="stop-color:#dadbdb;stop-opacity:1"
id="stop3206" /></linearGradient><linearGradient
id="linearGradient4966"><stop
offset="0"
style="stop-color:#b6b3d8;stop-opacity:1"
id="stop4968" /><stop
offset="1"
style="stop-color:#b6b3d8;stop-opacity:0"
id="stop4970" /></linearGradient><linearGradient
id="linearGradient4938"><stop
offset="0"
style="stop-color:#000000;stop-opacity:1"
id="stop4940" /><stop
offset="1"
style="stop-color:#000000;stop-opacity:0"
id="stop4942" /></linearGradient><linearGradient
id="linearGradient4282"><stop
offset="0"
style="stop-color:#bab8db;stop-opacity:1"
id="stop4900" /><stop
offset="1"
style="stop-color:#5955a9;stop-opacity:0.99159664"
id="stop4902" /></linearGradient><linearGradient
id="linearGradient4876"><stop
offset="0"
style="stop-color:#d3d2e8;stop-opacity:1"
id="stop4878" /><stop
offset="1"
style="stop-color:#5955a9;stop-opacity:0.99159664"
id="stop4880" /></linearGradient><radialGradient
gradientTransform="matrix(0.6817439,0,0,0.5905355,-3.8523706,-28.935273)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient4898"
id="radialGradient4892"
fy="-108.96888"
fx="20.951529"
r="266.76535"
cy="-108.96888"
cx="20.951529" /><radialGradient
gradientTransform="matrix(1,0,0,0.1854103,0,383.88493)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient4938"
id="radialGradient4944"
fy="471.26172"
fx="233.8876"
r="170.49393"
cy="471.26172"
cx="233.8876" /><radialGradient
gradientTransform="matrix(1,0,0,0.9121621,0,32.654948)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient4966"
id="radialGradient4972"
fy="371.76376"
fx="299.70135"
r="76.696358"
cy="371.76376"
cx="299.70135" /><radialGradient
gradientTransform="matrix(0.414705,0.3300575,-0.5059004,0.6356454,346.95314,49.479585)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3202"
id="radialGradient3210"
fy="390.45248"
fx="289.44067"
r="17.67668"
cy="390.45248"
cx="289.44067" /><radialGradient
gradientTransform="matrix(0.414705,0.3300575,-0.5059004,0.6356454,448.41009,-65.398074)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3202"
id="radialGradient3238"
fy="382.14804"
fx="283.50717"
r="17.67668"
cy="382.14804"
cx="283.50717" /><radialGradient
gradientTransform="matrix(-6.5565014e-2,-5.9721765e-2,1.6871024,-1.8521705,171.90774,540.51473)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3242"
id="radialGradient3248"
fy="181.18982"
fx="418.45551"
r="63.068935"
cy="181.18982"
cx="418.45551" /><radialGradient
gradientTransform="matrix(0.4055116,-3.3440123e-2,0.1034174,4.3988695,177.23251,-1191.6649)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3262"
id="radialGradient3268"
fy="357.33591"
fx="354.51709"
r="33.712105"
cy="357.33591"
cx="354.51709" /><radialGradient
gradientTransform="matrix(-0.1339874,-0.1146812,0.3079048,-0.3597394,444.23592,395.03849)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3274"
id="radialGradient3280"
fy="223.55537"
fx="510.58469"
r="132.28336"
cy="223.55537"
cx="510.58469" /><radialGradient
gradientTransform="matrix(-1.2497569,1.3798305,-9.6289463e-2,-7.2974479e-2,674.3826,-70.590682)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3284"
id="radialGradient3290"
fy="-158.17821"
fx="284.4671"
r="110.2972"
cy="-158.17821"
cx="284.4671" /><radialGradient
gradientTransform="matrix(-0.1008165,-8.0872321e-2,1.0745309,-1.3395252,13.843287,784.79288)"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3294"
id="radialGradient3300"
fy="356.62274"
fx="425.51019"
r="143.34167"
cy="356.62274"
cx="425.51019" /><filter
height="1.088351"
y="-0.044175496"
width="1.0892536"
x="-0.044626798"
id="filter4350"
style="color-interpolation-filters:sRGB"><feGaussianBlur
id="feGaussianBlur4352"
stdDeviation="8.7848425" /></filter><linearGradient
y2="300.73987"
x2="236.61363"
y1="-161.8512"
x1="-122.20192"
spreadMethod="pad"
gradientTransform="matrix(0.87385837,0,0,0.82818057,246.00762,250.28138)"
gradientUnits="userSpaceOnUse"
id="linearGradient4245"
xlink:href="#linearGradient3817" /><linearGradient
y2="66.018341"
x2="173.94518"
y1="396.6066"
x1="447.80933"
gradientTransform="matrix(0.98684959,0,0,0.98684959,3.0344187,2.5250397)"
gradientUnits="userSpaceOnUse"
id="linearGradient4247"
xlink:href="#linearGradient4292" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3961"
id="radialGradient3959"
cx="1136.9111"
cy="38.175797"
fx="1136.9111"
fy="38.175797"
r="233.11514"
gradientTransform="matrix(1,0,0,1.010216,-880.74005,217.63519)"
gradientUnits="userSpaceOnUse" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3961"
id="radialGradient3973"
cx="255.12297"
cy="256.89456"
fx="255.12297"
fy="256.89456"
r="239.78181"
gradientTransform="matrix(1,0,0,1.009932,0,-2.5514676)"
gradientUnits="userSpaceOnUse" /></defs><rect
style="fill:none;display:none"
id="rect4772"
y="0.20100001"
x="0.171"
height="512"
width="512" /><g
style="display:none"
id="g4788"><g
style="display:inline"
id="g4790" /></g><g
style="display:none"
id="g4806"><g
style="display:inline"
id="g4808"><path
style="fill:#050505;display:none"
id="path4810"
d="M 349.098,256.651 C 348.833,256.397 386.735,284.256 388.519,281.663 C 394.881,272.411 470.565,188.526 473.303,165.427 C 473.545,163.424 472.787,161.331 472.787,161.331 C 472.787,161.331 471.597,161.187 466.462,157.017 C 463.77,154.825 460.979,152.436 460.979,152.436 C 444.925,153.434 403.094,193.995 349.917,256.004" /></g></g><path
d="m 488.23812,256.89456 c 0,130.06121 -104.3692,235.49665 -233.1151,235.49665 -128.7459,0 -233.115201,-105.43544 -233.115201,-235.49665 0,-130.06123 104.369301,-235.49666 233.115201,-235.49666 128.7459,0 233.1151,105.43543 233.1151,235.49666 z"
id="path4235"
style="fill:url(#radialGradient3973);fill-opacity:1.0;stroke:#000000;stroke-width:13.33333301999999954;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
inkscape:connector-curvature="0" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -862.34934,-104.26336 c 0,-16.20664 13.138,-29.34464 29.34471,-29.34464 108.04422,0 195.63103,87.586807 195.63103,195.631095 0,16.206636 -13.138,29.344642 -29.34464,29.344642 -16.20664,0 -29.34464,-13.138006 -29.34464,-29.344642 0,-75.631014 -61.3108,-136.941754 -136.94175,-136.941754 -16.20671,0 -29.34471,-13.138067 -29.34471,-29.344701 z"
id="path4" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -725.40758,326.12504 c 0,16.20664 -13.13801,29.34464 -29.34464,29.34464 -108.04429,0 -195.63112,-87.58681 -195.63112,-195.63103 0,-16.20664 13.138,-29.34471 29.3447,-29.34471 16.2066,0 29.3447,13.13807 29.3447,29.34471 0,75.63095 61.3107,136.94175 136.94172,136.94175 16.20663,0 29.34464,13.138 29.34464,29.34464 z"
id="path6" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -1009.0726,179.40172 c -16.2067,0 -29.3447,-13.13801 -29.3447,-29.34464 0,-108.044293 87.58676,-195.631101 195.6311,-195.631101 16.20663,0 29.34464,13.138002 29.34464,29.344639 0,16.20663681 -13.13801,29.344702 -29.34464,29.344702 -75.63104,0 -136.94174,61.310741 -136.94174,136.94176 0,16.20663 -13.1381,29.34464 -29.34466,29.34464 z"
id="path8" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.64471179;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -578.68426,42.459959 c 16.20664,0 29.34464,13.138002 29.34464,29.344639 0,108.044292 -87.58681,195.631102 -195.63103,195.631102 -16.20664,0 -29.34471,-13.138 -29.34471,-29.34464 0,-16.20664 13.13807,-29.34464 29.34471,-29.34464 75.63095,0 136.94175,-61.3108 136.94175,-136.941822 0,-16.206637 13.138,-29.344639 29.34464,-29.344639 z"
id="path10" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -258.96103,188.38006 c -5.92272,0 -10.72401,-4.80129 -10.72401,-10.72404 0,-39.48479 32.00863,-71.49343 71.49346,-71.49343 5.92272,0 10.72401,4.80129 10.72401,10.72401 0,5.92273 -4.80129,10.72401 -10.72401,10.72401 -27.63938,0 -50.04542,22.40606 -50.04542,50.04541 0,5.92275 -4.80131,10.72404 -10.72403,10.72404 z"
id="path4-3" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -187.33996,241.78363 c 0,5.92274 -4.80129,10.72402 -10.72401,10.72402 -39.48482,0 -71.49346,-32.00861 -71.49346,-71.49346 0,-5.92272 4.80129,-10.72401 10.72401,-10.72401 5.92273,0 10.72403,4.80129 10.72403,10.72401 0,27.63939 22.40604,50.04541 50.04542,50.04541 5.92272,0 10.72401,4.80133 10.72401,10.72402 z"
id="path8-7" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -258.74453,313.27152 c -5.92272,0 -10.72401,-4.80129 -10.72401,-10.72401 0,-39.48483 32.00863,-71.49347 71.49344,-71.49347 5.92272,0 10.72403,4.80129 10.72403,10.72401 0,5.92273 -4.80131,10.72402 -10.72403,10.72402 -27.63937,0 -50.04542,22.40605 -50.04542,50.04544 0,5.92272 -4.80129,10.72401 -10.72401,10.72401 z"
id="path10-5" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.23561016;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m -187.12346,367.23573 c 0,5.92272 -4.80128,10.724 -10.72401,10.724 -39.48482,0 -71.49346,-32.00863 -71.49346,-71.49343 0,-5.92272 4.80129,-10.72403 10.72403,-10.72403 5.92271,0 10.72404,4.80131 10.72404,10.72403 0,27.63936 22.40601,50.04542 50.04539,50.04542 5.92273,0 10.72401,4.80128 10.72401,10.72401 z"
id="path6-6" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.29136932;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 629.26349,134.49819 c -5.17912,5.17913 -13.57608,5.17911 -18.75524,-3e-5 -34.52746,-34.527469 -34.52746,-90.507338 4e-5,-125.0348349 5.17912,-5.1791201 13.57609,-5.1791215 18.75521,2.1e-6 5.17912,5.1791198 5.1791,13.5760858 -10e-6,18.7551968 -24.16926,24.16926 -24.16922,63.355176 0,87.524396 5.17914,5.17914 5.17912,13.57614 0,18.75527 z"
id="path4-3-3" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.41281462;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 740.87146,587.17776 c -10.37726,0 -18.78964,-8.41245 -18.78964,-18.78967 0,-69.18171 56.08261,-125.26432 125.26429,-125.26432 10.37724,0 18.78967,8.41239 18.78967,18.78963 0,10.37726 -8.41243,18.78964 -18.78967,18.78964 -48.42717,0 -87.68502,39.25785 -87.68502,87.68505 0,10.37722 -8.41238,18.78967 -18.78963,18.78967 z"
id="path10-5-6" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.29136932;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 630.7519,243.88245 c -5.17914,5.17911 -13.5761,5.17911 -18.75523,0 -34.52748,-34.52751 -34.5275,-90.50737 0,-125.03487 5.17912,-5.1791 13.57607,-5.17908 18.75518,3e-5 5.17917,5.17907 5.17918,13.57606 5e-5,18.75519 -24.16926,24.16926 -24.16924,63.35515 2e-5,87.5244 5.17912,5.17912 5.17907,13.57615 -2e-5,18.75518 z"
id="path8-7-5" /><g
id="g3921"
transform="matrix(1.2623093,0,0,1.2623093,-22.620675,-167.67864)"><path
id="path4-9"
d="m -567.88395,525.90207 c 0,-8.09524 6.56246,-14.6577 14.65773,-14.6577 53.96826,0 97.71801,43.74975 97.71801,97.71804 0,8.09524 -6.56246,14.65769 -14.65769,14.65769 -8.09525,0 -14.6577,-6.56245 -14.6577,-14.65769 0,-37.7778 -30.62484,-68.40262 -68.40262,-68.40262 -8.09527,0 -14.65773,-6.56248 -14.65773,-14.65772 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /><path
id="path6-1"
d="m -499.48133,740.88175 c 0,8.09524 -6.56245,14.65768 -14.65769,14.65768 -53.96829,0 -97.71805,-43.74974 -97.71805,-97.718 0,-8.09524 6.56246,-14.65772 14.65772,-14.65772 8.09523,0 14.65773,6.56248 14.65773,14.65772 0,37.77778 30.62479,68.40262 68.4026,68.40262 8.09524,0 14.65769,6.56245 14.65769,14.6577 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /><path
id="path8-2"
d="m -641.17244,667.59321 c -8.09527,0 -14.65773,-6.56245 -14.65773,-14.65769 0,-53.96829 43.74973,-97.71804 97.71804,-97.71804 8.09524,0 14.6577,6.56246 14.6577,14.6577 0,8.09524 -6.56246,14.65772 -14.6577,14.65772 -37.77782,0 -68.40261,30.62481 -68.40261,68.40262 0,8.09524 -6.5625,14.65769 -14.6577,14.65769 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /><path
id="path10-7"
d="m -426.19279,599.19059 c 8.09524,0 14.65769,6.56246 14.65769,14.65769 0,53.9683 -43.74975,97.71805 -97.71801,97.71805 -8.09524,0 -14.65772,-6.56246 -14.65772,-14.65769 0,-8.09525 6.56248,-14.6577 14.65772,-14.6577 37.77778,0 68.40262,-30.62484 68.40262,-68.40266 0,-8.09523 6.56245,-14.65769 14.6577,-14.65769 z"
clip-rule="evenodd"
inkscape:connector-curvature="0"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.32203454;stroke-opacity:1" /></g><path
d="m 168.87017,369.7941 c 0,0 13.23506,0.93627 30.26137,-0.56431 6.89523,-0.60768 33.07453,-3.17912 52.64715,-7.47152 0,0 23.86373,-5.10715 36.63057,-9.81197 13.35843,-4.92285 20.62761,-9.10098 23.89954,-15.02127 -0.14269,-1.21302 1.00739,-5.51461 -5.15294,-8.09846 -15.74964,-6.60595 -34.01548,-5.41108 -70.15906,-6.1774 -40.0818,-1.37718 -53.41542,-8.08627 -60.51817,-13.48977 -6.81108,-5.48163 -3.38613,-20.64701 25.79772,-34.00541 14.70074,-7.11354 72.32932,-20.24098 72.32932,-20.24098 -19.40827,-9.59329 -55.59858,-26.458 -63.03787,-30.09979 -6.52468,-3.19401 -16.96616,-8.00329 -19.2295,-13.82188 -2.56614,-5.58603 6.06036,-10.39793 10.87905,-11.77591 15.51905,-4.47641 37.42722,-7.25864 57.3662,-7.571 10.0224,-0.15701 11.64921,-0.80185 11.64921,-0.80185 13.82901,-2.29394 22.93273,-11.75538 19.13973,-26.73949 -3.40511,-15.29487 -21.36391,-24.28197 -38.43005,-21.17074 -16.07116,2.92988 -54.80683,14.18154 -54.80683,14.18154 47.88016,-0.4144 55.89402,0.38474 59.47311,5.38881 2.11368,2.95526 -0.96045,7.00739 -13.7299,9.09291 -13.90189,2.27049 -42.80009,5.00476 -42.80009,5.00476 -27.72258,1.6464 -47.25033,1.75659 -53.10719,14.15679 -3.82632,8.10119 4.08038,15.26323 7.5459,19.74633 14.64462,16.28629 35.79785,25.06993 49.41383,31.53826 5.12311,2.43375 20.15489,7.02978 20.15489,7.02978 -44.17265,-2.42953 -76.03716,11.13432 -94.72864,26.75121 -21.14069,19.55411 -11.78868,42.86201 31.52274,57.21332 25.58149,8.47645 38.26825,12.46292 76.42687,9.02676 22.47583,-1.21144 26.01893,-0.49052 26.24301,1.35373 0.31548,2.59652 -24.96418,9.04641 -31.86578,11.03716 -17.55777,5.06447 -63.5838,15.29078 -63.81419,15.34039 z"
id="path4237"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-opacity:1;stroke-width:2.13333333000000014;stroke-miterlimit:4;stroke-dasharray:none"
inkscape:connector-curvature="0" /><path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#0dbd8b;stroke-width:0.29136932;stroke-opacity:1"
inkscape:connector-curvature="0"
clip-rule="evenodd"
d="m 741.14237,231.14899 c 5.17911,5.17911 5.17911,13.5761 0,18.75521 -34.5275,34.52749 -90.50738,34.5275 -125.03484,5e-5 -5.17911,-5.17912 -5.17915,-13.57613 -10e-6,-18.75527 5.17911,-5.17911 13.57615,-5.1791 18.75525,1e-5 24.16922,24.16921 63.35516,24.16927 87.5244,2e-5 5.17913,-5.17912 13.57606,-5.17915 18.7552,-2e-5 z"
id="path6-6-2" /><text
xml:space="preserve"
style="font-size:237.56724547999996844px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:#0dbd8b;font-family:Hack;-inkscape-font-specification:Hack;stroke-opacity:1;stroke-width:2.13333333000000014;stroke-miterlimit:4;stroke-dasharray:none"
x="295.67422"
y="342.85031"
id="text3927"
sodipodi:linespacing="125%"
transform="scale(0.97330289,1.0274294)"><tspan
sodipodi:role="line"
id="tspan3929"
x="295.67422"
y="342.85031"
style="font-size:237.56724547999996844px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;font-family:Hack;-inkscape-font-specification:Hack;stroke:#0dbd8b;stroke-opacity:1;stroke-width:2.13333333000000014;stroke-miterlimit:4;stroke-dasharray:none">l</tspan></text>
<text
xml:space="preserve"
style="font-size:138.19949341000000231px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:#0dbd8b;font-family:Hack;-inkscape-font-specification:Hack;stroke-opacity:1;stroke-width:2.13333333000000014;stroke-miterlimit:4;stroke-dasharray:none"
x="76.662376"
y="367.39389"
id="text3931"
sodipodi:linespacing="125%"><tspan
sodipodi:role="line"
id="tspan3933"
x="76.662376"
y="367.39389">.</tspan></text>
</svg>
This is dockAviIV.info, produced by makeinfo version 6.7 from
ement.texi.
INFO-DIR-SECTION Emacs
START-INFO-DIR-ENTRY
* Ement: (ement). Matrix client for Emacs.
END-INFO-DIR-ENTRY
File: dockAviIV.info, Node: Top, Next: Installation, Up: (dir)
Ement.el
********
https://elpa.gnu.org/packages/ement.svg
(https://elpa.gnu.org/packages/ement.html)
Ement.el is a Matrix client for Emacs. It aims to be simple, fast,
featureful, and reliable.
Feel free to join us in the chat room:
https://img.shields.io/matrix/ement.el:matrix.org.svg?label=%23ement.el:matrix.org
(https://matrix.to/#/#ement.el:matrix.org)
* Menu:
* Installation::
* Usage::
* Rationale::
* Changelog::
* Development::
* License::
— The Detailed Node Listing —
Installation
* GNU ELPA::
* GNU Guix::
* Debian::
* Git master::
* Manual::
Usage
* Bindings::
* Tips::
* Encrypted room support through Pantalaimon::
Bindings
* Room buffers::
* Room list buffer::
* Directory buffers::
* Mentions/notifications buffers::
Tips
* Displaying symbols and emojis::
Changelog
* 0.9.2: 092.
* 0.9.1: 091.
* 0.9: 09.
* 0.8.3: 083.
* 0.8.2: 082.
* 0.8.1: 081.
* 0.8: 08.
* 0.7: 07.
* 0.6: 06.
* 0.5.2: 052.
* 0.5.1: 051.
* 0.5: 05.
* 0.4.1: 041.
* 0.4: 04.
* 0.3.1: 031.
* 0.3: 03.
* 0.2.1: 021.
* 0.2: 02.
* 0.1.4: 014.
* 0.1.3: 013.
* 0.1.2: 012.
* 0.1.1: 011.
* 0.1: 01.
Development
* Copyright Assignment::
* Matrix spec in Org format::
File: dockAviIV.info, Node: Installation, Next: Usage, Prev: Top, Up: Top
1 Installation
**************
* Menu:
* GNU ELPA::
* GNU Guix::
* Debian::
* Git master::
* Manual::
File: dockAviIV.info, Node: GNU ELPA, Next: GNU Guix, Up: Installation
1.1 GNU ELPA
============
Ement.el is published in GNU ELPA (http://elpa.gnu.org/), so it may be
installed in Emacs with the command ‘M-x package-install RET ement RET’.
This is the recommended way to install Ement.el, as it will install the
current stable release.
File: dockAviIV.info, Node: GNU Guix, Next: Debian, Prev: GNU ELPA, Up: Installation
1.2 GNU Guix
============
Ement.el is also available in GNU Guix (https://guix.gnu.org/) as
‘emacs-ement’.
File: dockAviIV.info, Node: Debian, Next: Git master, Prev: GNU Guix, Up: Installation
1.3 Debian
==========
Ement.el is also available in Debian as elpa-ement
(https://packages.debian.org/elpa-ement).
File: dockAviIV.info, Node: Git master, Next: Manual, Prev: Debian, Up: Installation
1.4 Git master
==============
The ‘master’ branch of the Git repository is intended to be usable at
all times; only minor bugs are expected to be found in it before a new
stable release is made. To install from this, it is recommended to use
quelpa-use-package (https://github.com/quelpa/quelpa-use-package), like
this:
;; Install and load `quelpa-use-package'.
(package-install 'quelpa-use-package)
(require 'quelpa-use-package)
;; Install Ement.
(use-package ement
:quelpa (ement :fetcher github :repo "alphapapa/ement.el"))
One might also use systems like Straight
(https://github.com/radian-software/straight.el) (which is also used by
DOOM (https://github.com/doomemacs/doomemacs)) to install from Git, but
the author cannot offer support for them.
File: dockAviIV.info, Node: Manual, Prev: Git master, Up: Installation
1.5 Manual
==========
Ement.el is intended to be installed with Emacs’s package system, which
will ensure that the required autoloads are generated, etc. If you
choose to install it manually, you’re on your own.
File: dockAviIV.info, Node: Usage, Next: Rationale, Prev: Installation, Up: Top
2 Usage
*******
• • •
1. Call command ‘ement-connect’ to connect. Multiple sessions are
supported, so you may call the command again to connect to another
account.
2. Wait for initial sync to complete (which can take a few
moments–initial sync JSON requests can be large).
3. Use these commands (room-related commands may be called with
universal prefix to prompt for the room):
• ‘ement-list-rooms’ to view the list of joined rooms.
• ‘ement-view-room’ to view a room’s buffer, selected with
completion.
• ‘ement-create-room’ to create a new room.
• ‘ement-create-space’ to create a space.
• ‘ement-invite-user’ to invite a user to a room.
• ‘ement-join-room’ to join a room.
• ‘ement-leave-room’ to leave a room.
• ‘ement-forget-room’ to forget a room.
• ‘ement-tag-room’ to toggle a tag on a room (including
favorite/low-priority status).
• ‘ement-list-members’ to list members in a room.
• ‘ement-send-direct-message’ to send a direct message to a user
(in an existing direct room, or creating a new one
automatically).
• ‘ement-room-edit-message’ to edit a message at point.
• ‘ement-room-send-file’ to send a file.
• ‘ement-room-send-image’ to send an image.
• ‘ement-room-set-topic’ to set a room’s topic.
• ‘ement-room-occur’ to search in a room’s known events.
• ‘ement-room-override-name’ to override a room’s display name.
• ‘ement-ignore-user’ to ignore a user (or with interactive
prefix, un-ignore).
• ‘ement-room-set-message-format’ to set a room’s message format
buffer-locally.
• ‘ement-room-toggle-space’ to toggle a room’s membership in a
space (a way to group rooms in Matrix).
• ‘ement-directory’ to view a room directory.
• ‘ement-directory-search’ to search a room directory.
4. Use these special buffers to see events from multiple rooms (you
can also reply to messages from these buffers!):
• See all new events that mention you in the ‘*Ement Mentions*’
buffer.
• See all new events in rooms that have open buffers in the
‘*Ement Notifications*’ buffer.
* Menu:
* Bindings::
* Tips::
* Encrypted room support through Pantalaimon::
File: dockAviIV.info, Node: Bindings, Next: Tips, Up: Usage
2.1 Bindings
============
These bindings are common to all of the following buffer types:
• Switch to a room buffer: ‘M-g M-r’
• Switch to the room list buffer: ‘M-g M-l’
• Switch to the mentions buffer: ‘M-g M-m’
• Switch to the notifications buffer: ‘M-g M-n’
* Menu:
* Room buffers::
* Room list buffer::
* Directory buffers::
* Mentions/notifications buffers::
File: dockAviIV.info, Node: Room buffers, Next: Room list buffer, Up: Bindings
2.1.1 Room buffers
------------------
• Show command menu: ‘?’
*Movement*
• Next event: ‘TAB’
• Previous event: ‘<backtab>’
• Scroll up and mark read: ‘SPC’
• Scroll down: ‘S-SPC’
• Jump to fully-read marker: ‘M-SPC’
• Load older messages: at top of buffer, scroll contents up (i.e.
‘S-SPC’, ‘M-v’ or ‘mwheel-scroll’)
*Switching*
• List rooms: ‘M-g M-l’
• Switch to other room: ‘M-g M-r’
• Switch to mentions buffer: ‘M-g M-m’
• Switch to notifications buffer: ‘M-g M-n’
• Quit window: ‘q’
*Messages*
• Write message: ‘RET’
• Write reply to event at point (when region is active, only quote
marked text) : ‘S-RET’
• Compose message in buffer: ‘M-RET’ (while writing in minibuffer:
‘C-c ')’ (Use command ‘ement-room-compose-org’ to activate Org mode
in the compose buffer.)
• Edit message: ‘<insert>’
• Delete message: ‘C-k’
• Send reaction to event at point, or send same reaction at point: ‘s
r’
• Send emote: ‘s e’
• Send file: ‘s f’
• Send image: ‘s i’
• View event source: ‘v’
• Complete members and rooms at point: ‘C-M-i’ (standard
‘completion-at-point’ command). (Type an ‘@’ prefix for a member
mention, a ‘#’ prefix for a room alias, or a ‘!’ prefix for a room
ID.)
*Images*
• Toggle scale of image (between fit-to-window and thumbnail):
‘mouse-1’
• Show image in new buffer at full size: ‘double-mouse-1’
*Users*
• Send direct message: ‘u RET’
• Invite user: ‘u i’
• Ignore user: ‘u I’
*Room*
• Occur search in room: ‘M-s o’
• List members: ‘r m’
• Set topic: ‘r t’
• Set message format: ‘r f’
• Set notification rules: ‘r n’
• Override display name: ‘r N’
• Tag/untag room: ‘r T’
*Room membership*
• Create room: ‘R c’
• Join room: ‘R j’
• Leave room: ‘R l’
• Forget room: ‘R F’
• Toggle room’s spaces: ‘R s’
*Other*
• Sync new messages (not necessary if auto sync is enabled; with
prefix to force new sync): ‘g’
File: dockAviIV.info, Node: Room list buffer, Next: Directory buffers, Prev: Room buffers, Up: Bindings
2.1.2 Room list buffer
----------------------
• Show buffer of room at point: ‘RET’
• Show buffer of next unread room: ‘SPC’
• Move between room names: ‘TAB’ / ‘<backtab>’
• Kill room’s buffer: ‘k’
• Toggle room’s membership in a space: ‘s’
File: dockAviIV.info, Node: Directory buffers, Next: Mentions/notifications buffers, Prev: Room list buffer, Up: Bindings
2.1.3 Directory buffers
-----------------------
• View/join a room: ‘RET’ / ‘mouse-1’
• Load next batch of rooms: ‘+’
File: dockAviIV.info, Node: Mentions/notifications buffers, Prev: Directory buffers, Up: Bindings
2.1.4 Mentions/notifications buffers
------------------------------------
• Move between events: ‘TAB’ / ‘<backtab>’
• Go to event at point in its room buffer: ‘RET’
• Write reply to event at point (shows the event in its room while
writing) : ‘S-RET’
File: dockAviIV.info, Node: Tips, Next: Encrypted room support through Pantalaimon, Prev: Bindings, Up: Usage
2.2 Tips
========
• Desktop notifications are enabled by default for events that
mention the local user. They can also be shown for all events in
rooms with open buffers.
• Send messages in Org mode format by customizing the option
‘ement-room-send-message-filter’ (which enables Org format by
default), or by calling ‘ement-room-compose-org’ in a compose
buffer (which enables it for a single message). Then Org-formatted
messages are automatically converted and sent as HTML-formatted
messages (with the Org syntax as the plain-text fallback). You can
send syntax such as:
• Bold, italic, underline, strikethrough
• Links
• Tables
• Source blocks (including results with ‘:exports both’)
• Footnotes (okay, that might be pushing it, but you can!)
• And, generally, anything that Org can export to HTML
• Starting in the room list buffer, by pressing ‘SPC’ repeatedly, you
can cycle through and read all rooms with unread buffers. (If a
room doesn’t have a buffer, it will not be included.)
• Room buffers and the room-list buffer can be bookmarked in Emacs,
i.e. using ‘C-x r m’. This is especially useful with Burly
(https://github.com/alphapapa/burly.el): you can arrange an Emacs
frame with several room buffers displayed at once, use
‘burly-bookmark-windows’ to bookmark the layout, and then you can
restore that layout and all of the room buffers by opening the
bookmark, rather than having to manually arrange them every time
you start Emacs or change the window configuration.
• Images and other files can be uploaded to rooms using
drag-and-drop.
• Mention members by typing a ‘@’ followed by their displayname or
Matrix ID. (Members’ names and rooms’ aliases/IDs may be completed
with ‘completion-at-point’ commands.)
• You can customize settings in the ‘ement’ group.
• *Note:* ‘setq’ should not be used for certain options, because
it will not call the associated setter function. Users who
have an aversion to the customization system may experience
problems.
* Menu:
* Displaying symbols and emojis::
File: dockAviIV.info, Node: Displaying symbols and emojis, Up: Tips
2.2.1 Displaying symbols and emojis
-----------------------------------
Emacs may not display certain symbols and emojis well by default. Based
on this question and answer
(https://emacs.stackexchange.com/questions/62049/override-the-default-font-for-emoji-characters),
you may find that the simplest way to fix this is to install an
appropriate font, like Noto Emoji
(https://www.google.com/get/noto/#emoji-zsye), and then use this Elisp
code:
(setf use-default-font-for-symbols nil)
(set-fontset-font t 'unicode "Noto Emoji" nil 'append)
File: dockAviIV.info, Node: Encrypted room support through Pantalaimon, Prev: Tips, Up: Usage
2.3 Encrypted room support through Pantalaimon
==============================================
Ement.el doesn’t support encrypted rooms natively, but it can be used
transparently with the E2EE-aware reverse proxy daemon Pantalaimon
(https://github.com/matrix-org/pantalaimon/). After configuring it
according to its documentation, call ‘ement-connect’ with the
appropriate hostname and port, like:
(ement-connect :uri-prefix "http://localhost:8009")
File: dockAviIV.info, Node: Rationale, Next: Changelog, Prev: Usage, Up: Top
3 Rationale
***********
Why write a new Emacs Matrix client when there is already
matrix-client.el (https://github.com/alphapapa/matrix-client.el), by the
same author, no less? A few reasons:
• ‘matrix-client’ uses an older version of the Matrix spec, r0.3.0,
with a few elements of r0.4.0 grafted in. Bringing it up to date
with the current version of the spec, r0.6.1, would be more work
than to begin with the current version. Ement.el targets r0.6.1
from the beginning.
• ‘matrix-client’ does not use Matrix’s lazy-loading feature (which
was added to the specification later), so initial sync requests can
take a long time for the server to process and can be large
(sometimes tens of megabytes of JSON for the client to process!).
Ement.el uses lazy-loading, which significantly improves
performance.
• ‘matrix-client’ automatically makes buffers for every room a user
has joined, even if the user doesn’t currently want to watch a
room. Ement.el opens room buffers on-demand, improving performance
by not having to insert events into buffers for rooms the user
isn’t watching.
• ‘matrix-client’ was developed without the intention of publishing
it to, e.g. MELPA or ELPA. It has several dependencies, and its
code does not always install or compile cleanly due to
macro-expansion issues (apparently depending on the user’s Emacs
config). Ement.el is designed to have minimal dependencies outside
of Emacs (currently only one, ‘plz’, which could be imported into
the project), and every file is linted and compiles cleanly using
makem.sh (https://github.com/alphapapa/makem.sh).
• ‘matrix-client’ uses EIEIO, probably unnecessarily, since few, if
any, of the benefits of EIEIO are realized in it. Ement.el uses
structs instead.
• ‘matrix-client’ uses bespoke code for inserting messages into
buffers, which works pretty well, but has a few minor bugs which
are difficult to track down. Ement.el uses Emacs’s built-in (and
perhaps little-known) ‘ewoc’ library, which makes it much simpler
and more reliable to insert and update messages in buffers, and
enables the development of advanced UI features more easily.
• ‘matrix-client’ was, to a certain extent, designed to imitate other
messaging apps. The result is, at least when used with the
‘matrix-client-frame’ command, fairly pleasing to use, but isn’t
especially "Emacsy." Ement.el is intended to better fit into
Emacs’s paradigms.
• ‘matrix-client’’s long name makes for long symbol names, which
makes for tedious, verbose code. ‘ement’ is easy to type and makes
for concise, readable code.
• The author has learned much since writing ‘matrix-client’ and hopes
to write simpler, more readable, more maintainable code in
Ement.el. It’s hoped that this will enable others to contribute
more easily.
Note that, while ‘matrix-client’ remains usable, and probably will
for some time to come, Ement.el has now surpassed it in every way. The
only reason to choose ‘matrix-client’ instead is if one is using an
older version of Emacs that isn’t supported by Ement.el.
File: dockAviIV.info, Node: Changelog, Next: Development, Prev: Rationale, Up: Top
4 Changelog
***********
* Menu:
* 0.9.2: 092.
* 0.9.1: 091.
* 0.9: 09.
* 0.8.3: 083.
* 0.8.2: 082.
* 0.8.1: 081.
* 0.8: 08.
* 0.7: 07.
* 0.6: 06.
* 0.5.2: 052.
* 0.5.1: 051.
* 0.5: 05.
* 0.4.1: 041.
* 0.4: 04.
* 0.3.1: 031.
* 0.3: 03.
* 0.2.1: 021.
* 0.2: 02.
* 0.1.4: 014.
* 0.1.3: 013.
* 0.1.2: 012.
* 0.1.1: 011.
* 0.1: 01.
File: dockAviIV.info, Node: 092, Next: 091, Up: Changelog
4.1 0.9.2
=========
*Fixes*
• Restore position in room list when refreshing.
• Completion in minibuffer.
File: dockAviIV.info, Node: 091, Next: 09, Prev: 092, Up: Changelog
4.2 0.9.1
=========
*Fixes*
• Error in ‘ement-room-list’ command upon initial sync.
File: dockAviIV.info, Node: 09, Next: 083, Prev: 091, Up: Changelog
4.3 0.9
=======
*Additions*
• Option ‘ement-room-timestamp-header-align’ controls how timestamp
headers are aligned in room buffers.
• Option ‘ement-room-view-hook’ runs functions when ‘ement-room-view’
is called. (By default, it refreshes the room list buffer.)
• In the room list, middle-clicking a room which has a buffer closes
its buffer.
• Basic support for video events. (Thanks to Arto Jantunen
(https://github.com/viiru-).)
*Changes*
• Using new option ‘ement-room-timestamp-header-align’, timestamp
headers default to right-aligned. (With default settings, this
keeps them near message timestamps and makes for a cleaner
appearance.)
*Fixes*
• Recognition of certain MXID or displayname forms in outgoing
messages when linkifying (aka "pilling") them.
• Unreadable room avatar images no longer cause errors. (Fixes #147
(https://github.com/alphapapa/ement.el/issues/147). Thanks to
@jgarte (https://github.com/jgarte) for reporting.)
• Don’t error in ‘ement-room-list’ when no rooms are joined. (Fixes
#123 (https://github.com/alphapapa/ement.el/issues/123). Thanks to
@Kabouik (https://github.com/Kabouik) and Omar Antolín Camarena
(https://github.com/oantolin) for reporting.)
• Enable member/room completion in compose buffers. (Fixes #115
(https://github.com/alphapapa/ement.el/issues/115). Thanks to
Thanks to Justus Piater (https://github.com/piater) and Caleb Chase
(https://github.com/chasecaleb) for reporting.)
File: dockAviIV.info, Node: 083, Next: 082, Prev: 09, Up: Changelog
4.4 0.8.3
=========
*Fixes*
• Avoid use of ‘pcase’’s ‘(map :KEYWORD)’ form. (This can cause a
broken installation on older versions of Emacs that have an older
version of the ‘map’ library loaded, such as Emacs 27.2 included in
Debian 11. Since there’s no way to force Emacs to actually load
the version of ‘map’ required by this package before installing it
(which would naturally happen upon restarting Emacs), we can only
avoid using such forms while these versions of Emacs are widely
used.)
File: dockAviIV.info, Node: 082, Next: 081, Prev: 083, Up: Changelog
4.5 0.8.2
=========
*Fixes*
• Deduplicate grouped membership events.
File: dockAviIV.info, Node: 081, Next: 08, Prev: 082, Up: Changelog
4.6 0.8.1
=========
Added missing changelog entry (of course).
File: dockAviIV.info, Node: 08, Next: 07, Prev: 081, Up: Changelog
4.7 0.8
=======
*Additions*
• Command ‘ement-create-space’ creates a new space.
• Command ‘ement-room-toggle-space’ toggles a room’s membership in a
space (a way to group rooms in Matrix).
• Visibility of sections in the room list is saved across sessions.
• Command ‘ement-room-list-kill-buffer’ kills a room’s buffer from
the room list.
• Set ‘device_id’ and ‘initial_device_display_name’ upon login (e.g.
‘Ement.el: username@hostname’). (#134
(https://github.com/alphapapa/ement.el/issues/134). Thanks to Arto
Jantunen (https://github.com/viiru-) for reporting.)
*Changes*
• Room-related commands may be called interactively with a universal
prefix to prompt for the room/session (allowing to send events or
change settings in rooms other than the current one).
• Command ‘ement-room-list’ reuses an existing window showing the
room list when possible. (#131
(https://github.com/alphapapa/ement.el/issues/131). Thanks to Jeff
Bowman (https://github.com/jeffbowman) for suggesting.)
• Command ‘ement-tag-room’ toggles tags (rather than adding by
default and removing when called with a prefix).
• Default room grouping now groups "spaced" rooms separately.
*Fixes*
• Message format filter works properly when writing replies.
• Improve insertion of sender name headers when using the "Elemental"
message format.
• Prompts in commands ‘ement-leave-room’ and ‘ement-forget-room’.
File: dockAviIV.info, Node: 07, Next: 06, Prev: 08, Up: Changelog
4.8 0.7
=======
*Additions*
• Command ‘ement-room-override-name’ sets a local override for a
room’s display name. (Especially helpful for 1:1 rooms and bridged
rooms. See MSC3015
(https://github.com/matrix-org/matrix-spec-proposals/pull/3015#issuecomment-1451017296).)
*Changes*
• Improve display of room tombstones (displayed at top and bottom of
buffer, and new room ID is linked to join).
• Use descriptive prompts in ‘ement-leave-room’ and
‘ement-forget-room’ commands.
*Fixes*
• Command ‘ement-view-space’ when called from a room buffer. (Thanks
to Richard Brežák (https://github.com/MagicRB) for reporting.)
• Don’t call ‘display-buffer’ when reverting room list buffer.
(Fixes #121 (https://github.com/alphapapa/ement.el/issues/121).
Thanks to mekeor (https://github.com/mekeor) for reporting.)
• Retry sync for network timeouts. (Accidentally broken in v0.6.)
*Internal*
• Function ‘ement-put-account-data’ accepts ‘:room’ argument to put
on a room’s account data.
File: dockAviIV.info, Node: 06, Next: 052, Prev: 07, Up: Changelog
4.9 0.6
=======
*Additions*
• Command ‘ement-view-space’ to view a space’s rooms in a directory
buffer.
*Changes*
• Improve ‘ement-describe-room’ command (formatting, bindings).
*Fixes*
• Retry sync for HTTP 502 "Bad Gateway" errors.
• Formatting of unban events.
• Update password authentication according to newer Matrix spec.
(Fixes compatibility with Conduit servers. #66
(https://github.com/alphapapa/ement.el/issues/66). Thanks to
Travis Peacock (https://github.com/tpeacock19), Arto Jantunen
(https://github.com/viiru-), and Stephen D
(https://github.com/scd31).)
• Image scaling issues. (Thanks to Visuwesh
(https://github.com/vizs).)
File: dockAviIV.info, Node: 052, Next: 051, Prev: 06, Up: Changelog
4.10 0.5.2
==========
*Fixes*
• Apply ‘ement-initial-sync-timeout’ properly (important for when the
homeserver is slow to respond).
File: dockAviIV.info, Node: 051, Next: 05, Prev: 052, Up: Changelog
4.11 0.5.1
==========
*Fixes*
• Autoload ‘ement-directory’ commands.
• Faces in ‘ement-directory’ listings.
File: dockAviIV.info, Node: 05, Next: 041, Prev: 051, Up: Changelog
4.12 0.5
========
*Additions*
• Present "joined-and-left" and "rejoined-and-left" membership event
pairs as such.
• Process and show rooms’ canonical alias events.
*Changes*
• The taxy.el (https://github.com/alphapapa/taxy.el)-based room list,
with programmable, smart grouping, is now the default
‘ement-room-list’. (The old, ‘tabulated-list-mode’-based room list
is available as ‘ement-tabulated-room-list’.)
• When selecting a room to view with completion, don’t offer spaces.
• When selecting a room with completion, empty aliases and topics are
omitted instead of being displayed as nil.
*Fixes*
• Use of send-message filter when replying.
• Replies may be written in compose buffers.
File: dockAviIV.info, Node: 041, Next: 04, Prev: 05, Up: Changelog
4.13 0.4.1
==========
*Fixes*
• Don’t show "curl process interrupted" message when updating a read
marker’s position again.
File: dockAviIV.info, Node: 04, Next: 031, Prev: 041, Up: Changelog
4.14 0.4
========
*Additions*
• Option ‘ement-room-unread-only-counts-notifications’, now enabled
by default, causes rooms’ unread status to be determined only by
their notification counts (which are set by the server and depend
on rooms’ notification settings).
• Command ‘ement-room-set-notification-state’ sets a room’s
notification state (imitating Element’s user-friendly presets).
• Room buffers’ Transient menus show the room’s notification state
(imitating Element’s user-friendly presets).
• Command ‘ement-set-display-name’ sets the user’s global
displayname.
• Command ‘ement-room-set-display-name’ sets the user’s displayname
in a room (which is also now displayed in the room’s Transient
menu).
• Column ‘Notifications’ in the ‘ement-taxy-room-list’ buffer shows
rooms’ notification state.
• Option ‘ement-interrupted-sync-hook’ allows customization of how
sync interruptions are handled. (Now, by default, a warning is
displayed instead of merely a message.)
*Changes*
• When a room’s read receipt is updated, the room’s buffer is also
marked as unmodified. (In concert with the new option, this makes
rooms’ unread status more intuitive.)
*Fixes*
• Binding of command ‘ement-forget-room’ in room buffers.
• Highlighting of ‘@room’ mentions.
File: dockAviIV.info, Node: 031, Next: 03, Prev: 04, Up: Changelog
4.15 0.3.1
==========
*Fixes*
• Room unread status (when the last event in a room is sent by the
local user, the room is considered read).
File: dockAviIV.info, Node: 03, Next: 021, Prev: 031, Up: Changelog
4.16 0.3
========
*Additions*
• Command ‘ement-directory’ shows a server’s room directory.
• Command ‘ement-directory-search’ searches a server’s room
directory.
• Command ‘ement-directory-next’ fetches the next batch of rooms in a
directory.
• Command ‘ement-leave-room’ accepts a ‘FORCE-P’ argument
(interactively, with prefix) to leave a room without prompting.
• Command ‘ement-forget-room’ accepts a ‘FORCE-P’ argument
(interactively, with prefix) to also leave the room, and to forget
it without prompting.
• Option ‘ement-notify-mark-frame-urgent-predicates’ marks the frame
as urgent when (by default) a message mentions the local user or
"@room" and the message’s room has an open buffer.
*Changes*
• Minor improvements to date/time headers.
*Fixes*
• Command ‘ement-describe-room’ for rooms without topics.
• Improve insertion of old messages around existing timestamp
headers.
• Reduce D-Bus notification system check timeout to 2 seconds (from
the default of 25).
• Compatibility with Emacs 27.
File: dockAviIV.info, Node: 021, Next: 02, Prev: 03, Up: Changelog
4.17 0.2.1
==========
*Fixes*
• Info manual export filename.
File: dockAviIV.info, Node: 02, Next: 014, Prev: 021, Up: Changelog
4.18 0.2
========
*Changes*
• Read receipts are re-enabled. (They’re now implemented with a
global idle timer rather than ‘window-scroll-functions’, which
sometimes caused a strange race condition that could cause Emacs to
become unresponsive or crash.)
• When determining whether a room is considered unread, non-message
events like membership changes, reactions, etc. are ignored. This
fixes a bug that caused certain rooms that had no message events
(like some bridged rooms) to appear as unread when they shouldn’t
have. But it’s unclear whether this is always preferable (e.g.
one might want a member leaving a room to cause it to be marked
unread), so this is classified as a change rather than simply a
fix, and more improvements may be made to this in the future.
(Fixes #97 (https://github.com/alphapapa/ement.el/issues/97).
Thanks to Julien Roy (https://github.com/MrRoy) for reporting and
testing.)
• The ‘ement-taxy-room-list’ view no longer automatically refreshes
the list if the region is active in the buffer. (This allows the
user to operate on multiple rooms without the contents of the
buffer changing before completing the process.)
*Fixes*
• Links to only rooms (as opposed to links to events in rooms) may be
activated to join them.
• Read receipts mark the last completely visible event (rather than
one that’s only partially displayed).
• Prevent error when a room avatar image fails to load.
File: dockAviIV.info, Node: 014, Next: 013, Prev: 02, Up: Changelog
4.19 0.1.4
==========
*Fixed*
• Info manual directory headers.
File: dockAviIV.info, Node: 013, Next: 012, Prev: 014, Up: Changelog
4.20 0.1.3
==========
*Fixed*
• Temporarily disable sending of read receipts due to an unusual bug
that could cause Emacs to become unresponsive. (The feature will
be re-enabled in a future release.)
File: dockAviIV.info, Node: 012, Next: 011, Prev: 013, Up: Changelog
4.21 0.1.2
==========
*Fixed*
• Function ‘ement-room-sync’ correctly updates room-list buffers.
(Thanks to Visuwesh (https://github.com/vizs).)
• Only send D-Bus notifications when supported. (Fixes #83
(https://github.com/alphapapa/ement.el/issues/83). Thanks to
Tassilo Horn (https://github.com/tsdh).)
File: dockAviIV.info, Node: 011, Next: 01, Prev: 012, Up: Changelog
4.22 0.1.1
==========
*Fixed*
• Function ‘ement-room-scroll-up-mark-read’ selects the correct room
window.
• Option ‘ement-room-list-avatars’ defaults to what function
‘display-images-p’ returns.
File: dockAviIV.info, Node: 01, Prev: 011, Up: Changelog
4.23 0.1
========
After almost two years of development, the first tagged release.
Submitted to GNU ELPA.
File: dockAviIV.info, Node: Development, Next: License, Prev: Changelog, Up: Top
5 Development
*************
Bug reports, feature requests, suggestions — _oh my_!
* Menu:
* Copyright Assignment::
* Matrix spec in Org format::
File: dockAviIV.info, Node: Copyright Assignment, Next: Matrix spec in Org format, Up: Development
5.1 Copyright Assignment
========================
Ement.el is published in GNU ELPA and is considered part of GNU Emacs.
Therefore, cumulative contributions of more than 15 lines of code
require that the author assign copyright of such contributions to the
FSF. Authors who are interested in doing so may contact assign@gnu.org
<assign@gnu.org> to request the appropriate form.
File: dockAviIV.info, Node: Matrix spec in Org format, Prev: Copyright Assignment, Up: Development
5.2 Matrix spec in Org format
=============================
An Org-formatted version of the Matrix spec is available in the
meta/spec (https://github.com/alphapapa/ement.el/tree/meta/spec) branch.
File: dockAviIV.info, Node: License, Prev: Development, Up: Top
6 License
*********
GPLv3
Tag Table:
Node: Top188
Node: Installation1434
Node: GNU ELPA1619
Node: GNU Guix1968
Node: Debian2173
Node: Git master2384
Node: Manual3275
Node: Usage3571
Node: Bindings6207
Node: Room buffers6679
Node: Room list buffer9084
Node: Directory buffers9492
Node: Mentions/notifications buffers9763
Node: Tips10157
Node: Displaying symbols and emojis12583
Node: Encrypted room support through Pantalaimon13210
Node: Rationale13774
Node: Changelog17205
Node: 09217625
Node: 09117806
Node: 0917976
Node: 08319652
Node: 08220288
Node: 08120441
Node: 0820581
Node: 0722215
Node: 0623399
Node: 05224204
Node: 05124427
Node: 0524630
Node: 04125482
Node: 0425696
Node: 03127218
Node: 0327443
Node: 02128677
Node: 0228819
Node: 01430458
Node: 01330603
Node: 01230898
Node: 01131312
Node: 0131617
Node: Development31788
Node: Copyright Assignment32027
Node: Matrix spec in Org format32513
Node: License32817
End Tag Table
Local Variables:
coding: utf-8
End:
;;; ement.el --- Matrix client -*- lexical-binding: t; -*-
;; Copyright (C) 2022 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; URL: https://github.com/alphapapa/ement.el
;; Version: 0.9.2
;; Package-Requires: ((emacs "27.1") (map "2.1") (persist "0.5") (plz "0.2") (taxy "0.10") (taxy-magit-section "0.12.1") (svg-lib "0.2.5") (transient "0.3.7"))
;; Keywords: comm
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; Another Matrix client! This one is written from scratch and is
;; intended to be more "Emacsy," more suitable for MELPA, etc. Also
;; it has a shorter, perhaps catchier name, that is a mildly clever
;; play on the name of the official Matrix client and the Emacs Lisp
;; filename extension (oops, I explained the joke), which makes for
;; much shorter symbol names.
;; This file implements the core client library. Functions that may be called in multiple
;; files belong in `ement-lib'.
;;; Code:
;;;; Debugging
;; NOTE: Uncomment this form and `emacs-lisp-byte-compile-and-load' the file to enable
;; `ement-debug' messages. This is commented out by default because, even though the
;; messages are only displayed when `warning-minimum-log-level' is `:debug' at runtime, if
;; that is so at expansion time, the expanded macro calls format the message and check the
;; log level at runtime, which is not zero-cost.
;; (eval-and-compile
;; (require 'warnings)
;; (setq-local warning-minimum-log-level nil)
;; (setq-local warning-minimum-log-level :debug))
;;;; Requirements
;; Built in.
(require 'cl-lib)
(require 'dns)
(require 'files)
(require 'map)
;; This package.
(require 'ement-lib)
(require 'ement-room)
(require 'ement-notify)
;;;; Variables
(defvar ement-sessions nil
"Alist of active `ement-session' sessions, keyed by MXID.")
(defvar ement-syncs nil
"Alist of outstanding sync processes for each session.")
(defvar ement-users (make-hash-table :test #'equal)
;; NOTE: When changing the ement-user struct, it's necessary to
;; reset this table to clear old-type structs.
"Hash table storing user structs keyed on user ID.")
(defvar ement-progress-reporter nil
"Used to report progress while processing sync events.")
(defvar ement-progress-value nil
"Used to report progress while processing sync events.")
(defvar ement-sync-callback-hook
'(ement--update-room-buffers ement--auto-sync ement-tabulated-room-list-auto-update
ement-room-list-auto-update)
"Hook run after `ement--sync-callback'.
Hooks are called with one argument, the session that was
synced.")
(defvar ement-event-hook
'(ement-notify ement--process-event ement--put-event)
"Hook called for events.
Each function is called with three arguments: the event, the
room, and the session. This hook isn't intended to be modified
by users; ones who do so should know what they're doing.")
(defvar ement-default-sync-filter
'((room (state (lazy_load_members . t))
(timeline (lazy_load_members . t))))
"Default filter for sync requests.")
(defvar ement-images-queue (make-plz-queue :limit 5)
"`plz' HTTP request queue for image requests.")
(defvar ement-read-receipt-idle-timer nil
"Idle timer used to update read receipts.")
;; From other files.
(defvar ement-room-avatar-max-width)
(defvar ement-room-avatar-max-height)
;;;; Customization
(defgroup ement nil
"Options for Ement, the Matrix client."
:group 'comm)
(defcustom ement-save-sessions nil
"Save session to disk.
Writes the session file when Emacs is killed."
:type 'boolean
:set (lambda (option value)
(set-default option value)
(if value
(add-hook 'kill-emacs-hook #'ement--kill-emacs-hook)
(remove-hook 'kill-emacs-hook #'ement--kill-emacs-hook))))
(defcustom ement-sessions-file "~/.cache/ement.el"
;; FIXME: Expand correct XDG cache directory (new in Emacs 27).
"Save username and access token to this file."
:type 'file)
(defcustom ement-auto-sync t
"Automatically sync again after syncing."
:type 'boolean)
(defcustom ement-after-initial-sync-hook
'(ement-room-list--after-initial-sync ement-view-initial-rooms ement--link-children ement--run-idle-timer)
"Hook run after initial sync.
Run with one argument, the session synced."
:type 'hook)
(defcustom ement-initial-sync-timeout 40
"Timeout in seconds for initial sync requests.
For accounts in many rooms, the Matrix server may take some time
to prepare the initial sync response, and increasing this timeout
might be necessary."
:type 'integer)
(defcustom ement-auto-view-rooms nil
"Rooms to view after initial sync.
Alist mapping user IDs to a list of room aliases/IDs to open buffers for."
:type '(alist :key-type (string :tag "Local user ID")
:value-type (repeat (string :tag "Room alias/ID"))))
(defcustom ement-disconnect-hook '(ement-kill-buffers ement--stop-idle-timer)
;; FIXME: Put private functions in a private hook.
"Functions called when disconnecting.
That is, when calling command `ement-disconnect'. Functions are
called with no arguments."
:type 'hook)
(defcustom ement-view-room-display-buffer-action '(display-buffer-same-window)
"Display buffer action to use when opening room buffers.
See function `display-buffer' and info node `(elisp) Buffer
Display Action Functions'."
:type 'function)
(defcustom ement-auto-view-room-display-buffer-action '(display-buffer-no-window)
"Display buffer action to use when automatically opening room buffers.
That is, rooms listed in `ement-auto-view-rooms', which see. See
function `display-buffer' and info node `(elisp) Buffer Display
Action Functions'."
:type 'function)
(defcustom ement-interrupted-sync-hook '(ement-interrupted-sync-warning)
"Functions to call when syncing of a session is interrupted.
Only called when `ement-auto-sync' is non-nil. Functions are
called with one argument, the session whose sync was interrupted.
This hook allows the user to customize how sync interruptions are
handled (e.g. how to be notified)."
:type 'hook
:options '(ement-interrupted-sync-message ement-interrupted-sync-warning))
;;;; Commands
;;;###autoload
(cl-defun ement-connect (&key user-id password uri-prefix session)
"Connect to Matrix with USER-ID and PASSWORD, or using SESSION.
Interactively, with prefix, ignore a saved session and log in
again; otherwise, use a saved session if `ement-save-sessions' is
enabled and a saved session is available, or prompt to log in if
not enabled or available.
If USERID or PASSWORD are not specified, the user will be
prompted for them.
If URI-PREFIX is specified, it should be the prefix of the
server's API URI, including protocol, hostname, and optionally
the port, e.g.
\"https://matrix-client.matrix.org\"
\"http://localhost:8080\""
(interactive (if current-prefix-arg
;; Force new session.
(list :user-id (read-string "User ID: ")
:password (read-passwd "Password: "))
;; Use known session.
(unless ement-sessions
;; Read sessions from disk.
(condition-case err
(setf ement-sessions (ement--read-sessions))
(error (display-warning 'ement (format "Unable to read session data from disk (%s). Prompting to log in again."
(error-message-string err))))))
(cl-case (length ement-sessions)
(0 (list :user-id (read-string "User ID: ")
:password (read-passwd "Password: ")))
(1 (list :session (cdar ement-sessions)))
(otherwise (list :session (ement-complete-session))))))
(cl-labels ((new-session
() (unless (string-match (rx bos "@" (group (1+ (not (any ":")))) ; Username
":" (group (optional (1+ (not (any blank)))))) ; Server name
user-id)
(user-error "Invalid user ID format: use @USERNAME:SERVER"))
(let* ((username (match-string 1 user-id))
(server-name (match-string 2 user-id))
(uri-prefix (or uri-prefix (ement--hostname-uri server-name)))
(user (make-ement-user :id user-id :username username))
(server (make-ement-server :name server-name :uri-prefix uri-prefix))
(transaction-id (ement--initial-transaction-id))
(initial-device-display-name (format "Ement.el: %s@%s"
;; Just to be extra careful:
(or user-login-name "[unknown user-login-name]")
(or (system-name) "[unknown system-name]")))
(device-id (secure-hash 'sha256 initial-device-display-name)))
(make-ement-session :user user :server server :transaction-id transaction-id
:device-id device-id :initial-device-display-name initial-device-display-name
:events (make-hash-table :test #'equal))))
(password-login
() (pcase-let* (((cl-struct ement-session user device-id initial-device-display-name) session)
((cl-struct ement-user id) user)
(data (ement-alist "type" "m.login.password"
"identifier"
(ement-alist "type" "m.id.user"
"user" id)
"password" password
"device_id" device-id
"initial_device_display_name" initial-device-display-name)))
;; TODO: Clear password in callback (if we decide to hold on to it for retrying login timeouts).
(ement-api session "login" :method 'post :data (json-encode data)
:then (apply-partially #'ement--login-callback session))))
(flows-callback
(data) (if (cl-loop for flow across (map-elt data 'flows)
thereis (equal (map-elt flow 'type) "m.login.password"))
(progn
(message "Ement: Logging in with password...")
(password-login))
(error "Matrix server doesn't support m.login.password login flow. Supported flows: %s"
(cl-loop for flow in (map-elt data 'flows)
collect (map-elt flow 'type))))))
(if session
;; Start syncing given session.
(let ((user-id (ement-user-id (ement-session-user session))))
;; HACK: If session is already in ement-sessions, this replaces it. I think that's okay...
(setf (alist-get user-id ement-sessions nil nil #'equal) session)
(ement--sync session :timeout ement-initial-sync-timeout))
;; Start password login flow. Prompt for user ID and password
;; if not given (i.e. if not called interactively.)
(unless user-id
(setf user-id (read-string "User ID: ")))
(unless password
(setf password (read-passwd (format "Password for %s: " user-id))))
(setf session (new-session))
(when (ement-api session "login" :then #'flows-callback)
(message "Ement: Checking server's login flows...")))))
(defun ement-disconnect (sessions)
"Disconnect from SESSIONS.
Interactively, with prefix, disconnect from all sessions. If
`ement-auto-sync' is enabled, stop syncing, and clear the session
data. When enabled, write the session to disk. Any existing
room buffers are left alive and can be read, but other commands
in them won't work."
(interactive (list (if current-prefix-arg
(mapcar #'cdr ement-sessions)
(list (ement-complete-session)))))
(when ement-save-sessions
;; Write sessions before we remove them from the variable.
(ement--write-sessions ement-sessions))
(dolist (session sessions)
(let ((user-id (ement-user-id (ement-session-user session))))
(when-let ((process (map-elt ement-syncs session)))
(ignore-errors
(delete-process process)))
;; NOTE: I'd like to use `map-elt' here, but not until
;; <https://debbugs.gnu.org/cgi/bugreport.cgi?bug=47368> is fixed, I guess.
(setf (alist-get session ement-syncs nil nil #'equal) nil
(alist-get user-id ement-sessions nil 'remove #'equal) nil)))
(unless ement-sessions
;; HACK: If no sessions remain, clear the users table. It might be best
;; to store a per-session users table, but this is probably good enough.
(clrhash ement-users))
(run-hooks 'ement-disconnect-hook)
(message "Ement: Disconnected (%s)"
(string-join (cl-loop for session in sessions
collect (ement-user-id (ement-session-user session)))
", ")))
(defun ement-kill-buffers ()
"Kill all Ement buffers.
Useful in, e.g. `ement-disconnect-hook', which see."
(interactive)
(dolist (buffer (buffer-list))
(when (string-prefix-p "ement-" (symbol-name (buffer-local-value 'major-mode buffer)))
(kill-buffer buffer))))
(defun ement--login-callback (session data)
"Record DATA from logging in to SESSION and do initial sync."
(pcase-let* (((cl-struct ement-session (user (cl-struct ement-user (id user-id)))) session)
((map ('access_token token) ('device_id device-id)) data))
(setf (ement-session-token session) token
(ement-session-device-id session) device-id
(alist-get user-id ement-sessions nil nil #'equal) session)
(ement--sync session :timeout ement-initial-sync-timeout)))
;;;; Functions
(defun ement-interrupted-sync-warning (session)
"Display a warning that syncing of SESSION was interrupted."
(display-warning
'ement
(format
(substitute-command-keys
"\\<ement-room-mode-map>Syncing of session <%s> was interrupted. Use command `ement-room-sync' in a room buffer to retry.")
(ement-user-id (ement-session-user session)))
:error))
(defun ement-interrupted-sync-message (session)
"Display a message that syncing of SESSION was interrupted."
(message
(substitute-command-keys
"\\<ement-room-mode-map>Syncing of session <%s> was interrupted. Use command `ement-room-sync' in a room buffer to retry.")
(ement-user-id (ement-session-user session))))
(defun ement--run-idle-timer (&rest _ignore)
"Run idle timer that updates read receipts.
To be called from `ement-after-initial-sync-hook'. Timer is
stored in `ement-read-receipt-idle-timer'."
(setf ement-read-receipt-idle-timer (run-with-idle-timer 3 t #'ement-room-read-receipt-idle-timer)))
(defun ement--stop-idle-timer (&rest _ignore)
"Stop idle timer stored in `ement-read-receipt-idle-timer'.
To be called from `ement-disconnect-hook'."
(when (timerp ement-read-receipt-idle-timer)
(cancel-timer ement-read-receipt-idle-timer)
(setf ement-read-receipt-idle-timer nil)))
(defun ement-view-initial-rooms (session)
"View rooms for SESSION configured in `ement-auto-view-rooms'."
(when-let (rooms (alist-get (ement-user-id (ement-session-user session))
ement-auto-view-rooms nil nil #'equal))
(dolist (alias/id rooms)
(when-let (room (cl-find-if (lambda (room)
(or (equal alias/id (ement-room-canonical-alias room))
(equal alias/id (ement-room-id room))))
(ement-session-rooms session)))
(let ((ement-view-room-display-buffer-action ement-auto-view-room-display-buffer-action))
(ement-view-room room session))))))
(defun ement--initial-transaction-id ()
"Return an initial transaction ID for a new session."
;; We generate a somewhat-random initial transaction ID to avoid potential conflicts in
;; case, e.g. using Pantalaimon causes a transaction ID conflict. See
;; <https://github.com/alphapapa/ement.el/issues/36>.
(cl-parse-integer
(secure-hash 'sha256 (prin1-to-string (list (current-time) (system-name))))
:end 8 :radix 16))
(defsubst ement--sync-messages-p (session)
"Return non-nil if sync-related messages should be shown for SESSION."
;; For now, this seems like the best way.
(or (not (ement-session-has-synced-p session))
(not ement-auto-sync)))
(defun ement--hostname-uri (hostname)
"Return the \".well-known\" URI for server HOSTNAME.
If no URI is found, prompt the user for the hostname."
;; FIXME: When fail-prompting, a URI should be returned, not just a hostname.
;; SPEC: <https://matrix.org/docs/spec/client_server/r0.6.1#id178> ("4.1 Well-known URI")
(cl-labels ((fail-prompt
() (let ((input (read-string "Auto-discovery of server's well-known URI failed. Input server hostname, or leave blank to use server name: ")))
(pcase input
("" hostname)
(_ input))))
(parse (string)
(if-let ((object (ignore-errors (json-read-from-string string))))
;; Return extracted value.
(map-nested-elt object '(m.homeserver base_url))
;; Parsing error: FAIL_PROMPT.
(fail-prompt))))
(condition-case err
(let ((response (plz 'get (concat "https://" hostname "/.well-known/matrix/client")
:as 'response :then 'sync)))
(if (plz-response-p response)
(pcase (plz-response-status response)
(200 (parse (plz-response-body response)))
(404 (fail-prompt))
(_ (warn "Ement: `plz' request for .well-known URI returned unexpected code: %s"
(plz-response-status response))
(fail-prompt)))
(warn "Ement: `plz' request for .well-known URI did not return a `plz' response")
(fail-prompt)))
(error (warn "Ement: `plz' request for .well-known URI signaled an error: %S" err)
(fail-prompt)))))
(cl-defun ement--sync (session &key force quiet
(timeout 40) ;; Give the server an extra 10 seconds.
(filter ement-default-sync-filter))
"Send sync request for SESSION.
If SESSION has a `next-batch' token, it's used. If FORCE, first
delete any outstanding sync processes. If QUIET, don't show a
message about syncing this time. Cancel request after TIMEOUT
seconds.
FILTER may be an alist representing a raw event filter (i.e. not
a filter ID). When unspecified, the value of
`ement-default-sync-filter' is used. The filter is encoded with
`json-encode'. To use no filter, specify FILTER as nil."
;; SPEC: <https://matrix.org/docs/spec/client_server/r0.6.1#id257>.
;; TODO: Filtering: <https://matrix.org/docs/spec/client_server/r0.6.1#filtering>.
;; TODO: Use a filter ID for default filter.
;; TODO: Optionally, automatically sync again when HTTP request fails.
;; TODO: Ensure that the process in (map-elt ement-syncs session) is live.
(when (map-elt ement-syncs session)
(if force
(condition-case err
(delete-process (map-elt ement-syncs session))
;; Ensure the only error is the expected one from deleting the process.
(ement-api-error (cl-assert (equal "curl process killed" (plz-error-message (cl-third err))))
(message "Ement: Forcing new sync")))
(user-error "Ement: Already syncing this session")))
(pcase-let* (((cl-struct ement-session next-batch) session)
(params (remove
nil (list (list "full_state" (if next-batch "false" "true"))
(when filter
;; TODO: Document filter arg.
(list "filter" (json-encode filter)))
(when next-batch
(list "since" next-batch))
(when next-batch
(list "timeout" "30000")))))
(sync-start-time (time-to-seconds))
;; FIXME: Auto-sync again in error handler.
(process (ement-api session "sync" :params params
:timeout timeout
:then (apply-partially #'ement--sync-callback session)
:else (lambda (plz-error)
(setf (map-elt ement-syncs session) nil)
;; TODO: plz probably needs nicer error handling.
;; Ideally we would use `condition-case', but since the
;; error is signaled in `plz--sentinel'...
(pcase-let (((cl-struct plz-error curl-error response) plz-error)
(reason))
(cond ((when response
(pcase (plz-response-status response)
((or 429 502) (setf reason "failed")))))
((pcase curl-error
(`(28 . ,_) (setf reason "timed out")))))
(if reason
(if (not ement-auto-sync)
(run-hook-with-args 'ement-interrupted-sync-hook session)
(message "Ement: Sync %s (%s). Syncing again..."
reason (ement-user-id (ement-session-user session)))
;; Set QUIET to allow the just-printed message to remain visible.
(ement--sync session :timeout timeout :quiet t))
;; Unrecognized errors:
(pcase curl-error
(`(,code . ,message)
(signal 'ement-api-error (list (format "Ement: Network error: %s: %s" code message)
plz-error)))
(_ (signal 'ement-api-error (list "Ement: Unrecognized network error" plz-error)))))))
:json-read-fn (lambda ()
"Print a message, then call `json-read'."
(when (ement--sync-messages-p session)
(message "Ement: Response arrived after %.2f seconds. Reading %s JSON response..."
(- (time-to-seconds) sync-start-time)
(file-size-human-readable (buffer-size))))
(let ((start-time (time-to-seconds)))
(prog1 (json-read)
(when (ement--sync-messages-p session)
(message "Ement: Reading JSON took %.2f seconds"
(- (time-to-seconds) start-time)))))))))
(when process
(setf (map-elt ement-syncs session) process)
(when (and (not quiet) (ement--sync-messages-p session))
(message "Ement: Sync request sent, waiting for response...")))))
(defun ement--sync-callback (session data)
"Process sync DATA for SESSION.
Runs `ement-sync-callback-hook' with SESSION."
;; Remove the sync first. We already have the data from it, and the
;; process has exited, so it's safe to run another one.
(setf (map-elt ement-syncs session) nil)
(pcase-let* (((map rooms ('next_batch next-batch) ('account_data (map ('events account-data-events))))
data)
((map ('join joined-rooms) ('invite invited-rooms) ('leave left-rooms)) rooms)
(num-events (+
;; HACK: In `ement--push-joined-room-events', we do something
;; with each event 3 times, so we multiply this by 3.
;; FIXME: That calculation doesn't seem to be quite right, because
;; the progress reporter never seems to hit 100% before it's done.
(* 3 (cl-loop for (_id . room) in joined-rooms
sum (length (map-nested-elt room '(state events)))
sum (length (map-nested-elt room '(timeline events)))))
(cl-loop for (_id . room) in invited-rooms
sum (length (map-nested-elt room '(invite_state events)))))))
;; Append account data events.
;; TODO: Since only one event of each type is allowed in account data (the spec
;; doesn't seem to make this clear, but see
;; <https://github.com/matrix-org/matrix-js-sdk/blob/d0b964837f2820940bd93e718a2450b5f528bffc/src/store/memory.ts#L292>),
;; we should store account-data events in a hash table or alist rather than just a
;; list of events.
(cl-callf2 append (cl-coerce account-data-events 'list) (ement-session-account-data session))
;; Process invited and joined rooms.
(ement-with-progress-reporter (:when (ement--sync-messages-p session)
:reporter ("Ement: Reading events..." 0 num-events))
;; Left rooms.
(mapc (apply-partially #'ement--push-left-room-events session) left-rooms)
;; Invited rooms.
(mapc (apply-partially #'ement--push-invite-room-events session) invited-rooms)
;; Joined rooms.
(mapc (apply-partially #'ement--push-joined-room-events session) joined-rooms))
;; TODO: Process "left" rooms (remove room structs, etc).
;; NOTE: We update the next-batch token before updating any room buffers. This means
;; that any errors in updating room buffers (like for unexpected event formats that
;; expose a bug) could cause events to not appear in the buffer, but the user could
;; still dismiss the error and start syncing again, and the client could remain
;; usable. Updating the token after doing everything would be preferable in some
;; ways, but it would mean that an event that exposes a bug would be processed again
;; on every sync, causing the same error each time. It would seem preferable to
;; maintain at least some usability rather than to keep repeating a broken behavior.
(setf (ement-session-next-batch session) next-batch)
;; Run hooks which update buffers, etc.
(run-hook-with-args 'ement-sync-callback-hook session)
;; Show sync message if appropriate, and run after-initial-sync-hook.
(when (ement--sync-messages-p session)
(message (concat "Ement: Sync done."
(unless (ement-session-has-synced-p session)
(run-hook-with-args 'ement-after-initial-sync-hook session)
;; Show tip after initial sync.
(setf (ement-session-has-synced-p session) t)
" Use commands `ement-list-rooms' or `ement-view-room' to view a room."))))))
(defun ement--push-invite-room-events (session invited-room)
"Push events for INVITED-ROOM into that room in SESSION."
;; TODO: Make ement-session-rooms a hash-table.
(ement--push-joined-room-events session invited-room 'invite))
(defun ement--auto-sync (session)
"If `ement-auto-sync' is non-nil, sync SESSION again."
(when ement-auto-sync
(ement--sync session)))
(defun ement--update-room-buffers (session)
"Insert new events into SESSION's rooms which have buffers.
To be called in `ement-sync-callback-hook'."
;; TODO: Move this to ement-room.el, probably.
;; For now, we primitively iterate over the buffer list to find ones
;; whose mode is `ement-room-mode'.
(let* ((buffers (cl-loop for room in (ement-session-rooms session)
for buffer = (map-elt (ement-room-local room) 'buffer)
when (buffer-live-p buffer)
collect buffer)))
(dolist (buffer buffers)
(with-current-buffer buffer
(save-window-excursion
;; NOTE: When the buffer has a window, it must be the selected one
;; while calling event-insertion functions. I don't know if this is
;; due to a bug in EWOC or if I just misunderstand something, but
;; without doing this, events may be inserted at the wrong place.
(when-let ((buffer-window (get-buffer-window buffer)))
(select-window buffer-window))
(cl-assert ement-room)
(when (ement-room-ephemeral ement-room)
;; Ephemeral events.
(ement-room--process-events (ement-room-ephemeral ement-room))
(setf (ement-room-ephemeral ement-room) nil))
(when-let ((new-events (alist-get 'new-events (ement-room-local ement-room))))
;; HACK: Process these events in reverse order, so that later events (like reactions)
;; which refer to earlier events can find them. (Not sure if still necessary.)
(ement-room--process-events (reverse new-events))
(setf (alist-get 'new-events (ement-room-local ement-room)) nil))
(when-let ((new-events (alist-get 'new-account-data-events (ement-room-local ement-room))))
;; Account data events. Do this last so, e.g. read markers can refer to message events we've seen.
(ement-room--process-events new-events)
(setf (alist-get 'new-account-data-events (ement-room-local ement-room)) nil)))))))
(cl-defun ement--push-joined-room-events (session joined-room &optional (status 'join))
"Push events for JOINED-ROOM into that room in SESSION.
Also used for left rooms, in which case STATUS should be set to
`leave'."
(pcase-let* ((`(,id . ,event-types) joined-room)
(id (symbol-name id)) ; Really important that the ID is a STRING!
;; TODO: Make ement-session-rooms a hash-table.
(room (or (cl-find-if (lambda (room)
(equal id (ement-room-id room)))
(ement-session-rooms session))
(car (push (make-ement-room :id id) (ement-session-rooms session)))))
((map summary state ephemeral timeline
('invite_state (map ('events invite-state-events)))
('account_data (map ('events account-data-events)))
('unread_notifications unread-notifications))
event-types)
(latest-timestamp))
(setf (ement-room-status room) status
(ement-room-unread-notifications room) unread-notifications)
;; NOTE: The idea is that, assuming that events in the sync reponse are in
;; chronological order, we push them to the lists in the room slots in that order,
;; leaving the head of each list as the most recent event of that type. That means
;; that, e.g. the room state events may be searched in order to find, e.g. the most
;; recent room name event. However, chronological order is not guaranteed, e.g. after
;; loading older messages (the "retro" function; this behavior is in development).
;; MAYBE: Use queue.el to store the events in a DLL, so they could
;; be accessed from either end. Could be useful.
;; Push the StrippedState events to the room's invite-state. (These events have no
;; timestamp data.) We also run the event hook, because for invited rooms, the
;; invite-state events include room name, topic, etc.
(cl-loop for event across-ref invite-state-events do
(setf event (ement--make-event event))
(push event (ement-room-invite-state room))
(run-hook-with-args 'ement-event-hook event room session))
;; Save room summary.
(dolist (parameter '(m.heroes m.joined_member_count m.invited_member_count))
(when (alist-get parameter summary)
;; These fields are only included when they change.
(setf (alist-get parameter (ement-room-summary room)) (alist-get parameter summary))))
;; Update account data. According to the spec, only one of each event type is
;; supposed to be present in a room's account data, so we store them as an alist keyed
;; on their type. (NOTE: We don't currently make them into event structs, but maybe
;; we should in the future.)
(cl-loop for event across account-data-events
for type = (alist-get 'type event)
do (setf (alist-get type (ement-room-account-data room) nil nil #'equal) event))
;; But we also need to track just the new events so we can process those in a room
;; buffer (and for some reason, we do make them into structs here, but I don't
;; remember why). FIXME: Unify this.
(cl-callf2 append (mapcar #'ement--make-event account-data-events)
(alist-get 'new-account-data-events (ement-room-local room)))
;; Save state and timeline events.
(cl-macrolet ((push-events
(type accessor)
;; Push new events of TYPE to room's slot of ACCESSOR, and return the latest timestamp pushed.
`(let ((ts 0))
;; NOTE: We replace each event in the vector with the
;; struct, which is used when calling hooks later.
(cl-loop for event across-ref (alist-get 'events ,type)
do (setf event (ement--make-event event))
do (push event (,accessor room))
(when (ement--sync-messages-p session)
(ement-progress-update))
(when (> (ement-event-origin-server-ts event) ts)
(setf ts (ement-event-origin-server-ts event))))
;; One would think that one should use `maximizing' here, but, completely
;; inexplicably, it sometimes returns nil, even when every single value it's comparing
;; is a number. It's absolutely bizarre, but I have to do the equivalent manually.
ts)))
;; FIXME: This is a bit convoluted and hacky now. Refactor it.
(setf latest-timestamp
(max (push-events state ement-room-state)
(push-events timeline ement-room-timeline)))
;; NOTE: We also append the new events to the new-events list in the room's local
;; slot, which is used by `ement--update-room-buffers' to insert only new events.
;; FIXME: Does this also need to be done for invite-state events?
(cl-callf2 append (cl-coerce (alist-get 'events timeline) 'list)
(alist-get 'new-events (ement-room-local room)))
;; Update room's latest-timestamp slot.
(when (> latest-timestamp (or (ement-room-latest-ts room) 0))
(setf (ement-room-latest-ts room) latest-timestamp))
(unless (ement-session-has-synced-p session)
;; Only set this token on initial sync, otherwise it would
;; overwrite earlier tokens from loading earlier messages.
(setf (ement-room-prev-batch room) (alist-get 'prev_batch timeline))))
;; Run event hook for state and timeline events.
(cl-loop for event across (alist-get 'events state)
do (run-hook-with-args 'ement-event-hook event room session)
(when (ement--sync-messages-p session)
(ement-progress-update)))
(cl-loop for event across (alist-get 'events timeline)
do (run-hook-with-args 'ement-event-hook event room session)
(when (ement--sync-messages-p session)
(ement-progress-update)))
;; Ephemeral events (do this after state and timeline hooks, so those events will be
;; in the hash tables).
(cl-loop for event across (alist-get 'events ephemeral)
for event-struct = (ement--make-event event)
do (push event-struct (ement-room-ephemeral room))
(ement--process-event event-struct room session))
(when (ement-session-has-synced-p session)
;; NOTE: We don't fill gaps in "limited" requests on initial
;; sync, only in subsequent syncs, e.g. after the system has
;; slept and awakened.
;; NOTE: When not limited, the read value is `:json-false', so
;; we must explicitly compare to t.
(when (eq t (alist-get 'limited timeline))
;; Timeline was limited: start filling gap. We start the
;; gap-filling, retrieving up to the session's current
;; next-batch token (this function is not called when retrieving
;; older messages, so the session's next-batch token is only
;; evaluated once, when this chain begins, and then that token
;; is passed to repeated calls to `ement-room-retro-to-token'
;; until the gap is filled).
(ement-room-retro-to-token room session (alist-get 'prev_batch timeline)
(ement-session-next-batch session))))))
(defun ement--push-left-room-events (session left-room)
"Push events for LEFT-ROOM into that room in SESSION."
(ement--push-joined-room-events session left-room 'leave))
(defun ement--make-event (event)
"Return `ement-event' struct for raw EVENT list.
Adds sender to `ement-users' when necessary."
(pcase-let* (((map content type unsigned redacts
('event_id id) ('origin_server_ts ts)
('sender sender-id) ('state_key state-key))
event)
(sender (or (gethash sender-id ement-users)
(puthash sender-id (make-ement-user :id sender-id)
ement-users))))
;; MAYBE: Handle other keys in the event, such as "room_id" in "invite" events.
(make-ement-event :id id :sender sender :type type :content content :state-key state-key
:origin-server-ts ts :unsigned unsigned
;; Since very few events will be redactions and have this key, we
;; record it in the local slot alist rather than as another slot on
;; the struct.
:local (when redacts
(ement-alist 'redacts redacts)))))
(defun ement--put-event (event _room session)
"Put EVENT on SESSION's events table."
(puthash (ement-event-id event) event (ement-session-events session)))
;; FIXME: These functions probably need to compare timestamps to
;; ensure that older events that are inserted at the head of the
;; events lists aren't used instead of newer ones.
;; TODO: These two functions should be folded into event handlers.
;;;;; Reading/writing sessions
(defun ement--read-sessions ()
"Return saved sessions alist read from disk.
Returns nil if unable to read `ement-sessions-file'."
(cl-labels ((plist-to-session
(plist) (pcase-let* (((map (:user user-data) (:server server-data)
(:token token) (:transaction-id transaction-id))
plist)
(user (apply #'make-ement-user user-data))
(server (apply #'make-ement-server server-data))
(session (make-ement-session :user user :server server
:token token :transaction-id transaction-id)))
(setf (ement-session-events session) (make-hash-table :test #'equal))
session)))
(when (file-exists-p ement-sessions-file)
(pcase-let* ((read-circle t)
(sessions (with-temp-buffer
(insert-file-contents ement-sessions-file)
(read (current-buffer)))))
(prog1
(cl-loop for (id . plist) in sessions
collect (cons id (plist-to-session plist)))
(message "Ement: Read sessions."))))))
(defun ement--write-sessions (sessions-alist)
"Write SESSIONS-ALIST to disk."
;; We only record the slots we need. We record them as a plist
;; so that changes to the struct definition don't matter.
;; NOTE: If we ever persist more session data (like room data, so we
;; could avoid doing an initial sync next time), we should limit the
;; amount of session data saved (e.g. room history could grow
;; forever on-disk, which probably isn't what we want).
;; NOTE: This writes all current sessions, even if there are multiple active ones and only one
;; is being disconnected. That's probably okay, but it might be something to keep in mind.
(cl-labels ((session-plist
(session) (pcase-let* (((cl-struct ement-session user server token transaction-id) session)
((cl-struct ement-user (id user-id) username) user)
((cl-struct ement-server (name server-name) uri-prefix) server))
(list :user (list :id user-id
:username username)
:server (list :name server-name
:uri-prefix uri-prefix)
:token token
:transaction-id transaction-id))))
(message "Ement: Writing sessions...")
(with-temp-file ement-sessions-file
(pcase-let* ((print-level nil)
(print-length nil)
;; Very important to use `print-circle', although it doesn't
;; solve everything. Writing/reading Lisp data can be tricky...
(print-circle t)
(sessions-alist-plist (cl-loop for (id . session) in sessions-alist
collect (cons id (session-plist session)))))
(prin1 sessions-alist-plist (current-buffer))))
;; Ensure permissions are safe.
(chmod ement-sessions-file #o600)))
(defun ement--kill-emacs-hook ()
"Function to be added to `kill-emacs-hook'.
Writes Ement session to disk when enabled."
(ignore-errors
;; To avoid interfering with Emacs' exit, We must be careful that
;; this function handles errors, so just ignore any.
(when (and ement-save-sessions
ement-sessions)
(ement--write-sessions ement-sessions))))
;;;;; Event handlers
(defvar ement-event-handlers nil
"Alist mapping event types to functions which process an event of each type.
Each function is called with three arguments: the event, the
room, and the session. These handlers are run regardless of
whether a room has a live buffer.")
(defun ement--process-event (event room session)
"Process EVENT for ROOM in SESSION.
Uses handlers defined in `ement-event-handlers'. If no handler
is defined for EVENT's type, does nothing and returns nil. Any
errors signaled during processing are demoted in order to prevent
unexpected errors from arresting event processing and syncing."
(when-let ((handler (alist-get (ement-event-type event) ement-event-handlers nil nil #'equal)))
;; We demote any errors that happen while processing events, because it's possible for
;; events to be malformed in unexpected ways, and that could cause an error, which
;; would stop processing of other events and prevent further syncing. See,
;; e.g. <https://github.com/alphapapa/ement.el/pull/61>.
(with-demoted-errors "Ement (ement--process-event): Error processing event: %S"
(funcall handler event room session))))
(defmacro ement-defevent (type &rest body)
"Define an event handling function for events of TYPE, a string.
Around the BODY, the variable `event' is bound to the event being
processed, `room' to the room struct in which the event occurred,
and `session' to the session. Adds function to
`ement-event-handlers', which see."
(declare (indent defun))
`(setf (alist-get ,type ement-event-handlers nil nil #'string=)
(lambda (event room session)
,(concat "`ement-' handler function for " type " events.")
,@body)))
;; I love how Lisp macros make it so easy and concise to define these
;; event handlers!
(ement-defevent "m.room.avatar"
(when ement-room-avatars
;; If room avatars are disabled, we don't download avatars at all. This
;; means that, if a user has them disabled and then reenables them, they will
;; likely need to reconnect to cause them to be displayed in most rooms.
(if-let ((url (alist-get 'url (ement-event-content event))))
(plz-run
(plz-queue ement-images-queue
'get (ement--mxc-to-url url session) :as 'binary :noquery t
:then (lambda (data)
(when ement-room-avatars
;; MAYBE: Store the raw image data instead of using create-image here.
(let ((image (create-image data nil 'data-p
:ascent 'center
:max-width ement-room-avatar-max-width
:max-height ement-room-avatar-max-height)))
(if (not image)
(progn
(display-warning 'ement (format "Room avatar seems unreadable: ROOM-ID:%S AVATAR-URL:%S"
(ement-room-id room) (ement--mxc-to-url url session)))
(setf (ement-room-avatar room) nil
(alist-get 'room-list-avatar (ement-room-local room)) nil))
(when (fboundp 'imagemagick-types)
;; Only do this when ImageMagick is supported.
;; FIXME: When requiring Emacs 27+, remove this (I guess?).
(setf (image-property image :type) 'imagemagick))
;; We set the room-avatar slot to a propertized string that
;; displays as the image. This seems the most convenient thing to
;; do. We also unset the cached room-list-avatar so it can be
;; remade.
(setf (ement-room-avatar room) (propertize " " 'display image)
(alist-get 'room-list-avatar (ement-room-local room)) nil)))))))
;; Unset avatar.
(setf (ement-room-avatar room) nil
(alist-get 'room-list-avatar (ement-room-local room)) nil))))
(ement-defevent "m.room.create"
(ignore session)
(pcase-let* (((cl-struct ement-event (content (map type))) event))
(when type
(setf (ement-room-type room) type))))
(ement-defevent "m.room.member"
"Put/update member on `ement-users' and room's members table."
(ignore session)
(pcase-let* (((cl-struct ement-room members) room)
((cl-struct ement-event state-key
(content (map displayname membership
('avatar_url avatar-url))))
event)
(user (or (gethash state-key ement-users)
(puthash state-key
(make-ement-user :id state-key :avatar-url avatar-url
;; NOTE: The spec doesn't seem to say whether the
;; displayname in the member event applies only to the
;; room or is for the user generally, so we'll save it
;; in the struct anyway.
:displayname displayname)
ement-users))))
(pcase membership
("join"
(puthash state-key user members)
(puthash user displayname (ement-room-displaynames room)))
(_ (remhash state-key members)
(remhash user (ement-room-displaynames room))))))
(ement-defevent "m.room.name"
(ignore session)
(pcase-let* (((cl-struct ement-event (content (map name))) event))
(when name
;; Recalculate room name and cache in slot.
(setf (ement-room-display-name room) (ement--room-display-name room)))))
(ement-defevent "m.room.topic"
(ignore session)
(pcase-let* (((cl-struct ement-event (content (map topic))) event))
(when topic
(setf (ement-room-topic room) topic))))
(ement-defevent "m.receipt"
(ignore session)
(pcase-let (((cl-struct ement-event content) event)
((cl-struct ement-room (receipts room-receipts)) room))
(cl-loop for (event-id . receipts) in content
do (cl-loop for (user-id . receipt) in (alist-get 'm.read receipts)
;; Users may not have been "seen" yet, so although we'd
;; prefer to key on the user struct, we key on the user ID.
;; Same for events, unfortunately.
;; NOTE: The JSON map keys are converted to symbols by `json-read'.
;; MAYBE: (Should we keep them that way? It would use less memory, I guess.)
do (puthash (symbol-name user-id)
(cons (symbol-name event-id) (alist-get 'ts receipt))
room-receipts)))))
(ement-defevent "m.space.child"
;; SPEC: v1.2/11.35.
(pcase-let* ((space-room room)
((cl-struct ement-session rooms) session)
((cl-struct ement-room (id parent-room-id)) space-room)
((cl-struct ement-event (state-key child-room-id) (content (map via))) event)
(child-room (cl-find child-room-id rooms :key #'ement-room-id :test #'equal)))
(if via
;; Child being declared: add it.
(progn
(cl-pushnew child-room-id (alist-get 'children (ement-room-local space-room)) :test #'equal)
(when child-room
;; The user is also in the child room: link the parent space-room in it.
;; FIXME: On initial sync, if the child room hasn't been processed yet, this will fail.
(cl-pushnew parent-room-id (alist-get 'parents (ement-room-local child-room)) :test #'equal)))
;; Child being disowned: remove it.
(setf (alist-get 'children (ement-room-local space-room))
(delete child-room-id (alist-get 'children (ement-room-local space-room))))
(when child-room
;; The user is also in the child room: unlink the parent space-room in it.
(setf (alist-get 'parents (ement-room-local child-room))
(delete parent-room-id (alist-get 'parents (ement-room-local child-room))))))))
(ement-defevent "m.room.canonical_alias"
(ignore session)
(pcase-let (((cl-struct ement-event (content (map alias))) event))
(setf (ement-room-canonical-alias room) alias)))
(defun ement--link-children (session)
"Link child rooms in SESSION.
To be called after initial sync."
;; On initial sync, when processing m.space.child events, the child rooms may not have
;; been processed yet, so we link them again here.
(pcase-let (((cl-struct ement-session rooms) session))
(dolist (room rooms)
(pcase-let (((cl-struct ement-room (id parent-id) (local (map children))) room))
(when children
(dolist (child-id children)
(when-let ((child-room (cl-find child-id rooms :key #'ement-room-id :test #'equal)))
(cl-pushnew parent-id (alist-get 'parents (ement-room-local child-room)) :test #'equal))))))))
;;;; Footer
(provide 'ement)
;;; ement.el ends here
;;; ement-tabulated-room-list.el --- Ement tabulated room list buffer -*- lexical-binding: t; -*-
;; Copyright (C) 2022 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; This library implements a room list buffer with `tabulated-list-mode'.
;; NOTE: It doesn't appear that there is a way to get the number of
;; members in a room other than by retrieving the list of members and
;; counting them. For a large room (e.g. the Spacemacs Gitter room or
;; #debian:matrix.org), that means thousands of users, none of the
;; details of which we care about. So it seems impractical to know
;; the number of members when using lazy-loading. So I guess we just
;; won't show the number of members.
;; TODO: (Or maybe there is, see m.joined_member_count).
;; NOTE: The tabulated-list API is awkward here. When the
;; `tabulated-list-format' is changed, we have to make the change in 4
;; or 5 other places, and if one forgets to, bugs with non-obvious
;; causes happen. I think library using EIEIO or structs would be
;; very helpful.
;;; Code:
;;;; Requirements
(require 'cl-lib)
(require 'tabulated-list)
(require 'ement)
;;;; Variables
(declare-function ement-notify-switch-to-mentions-buffer "ement-notify")
(declare-function ement-notify-switch-to-notifications-buffer "ement-notify")
(defvar ement-tabulated-room-list-mode-map
(let ((map (make-sparse-keymap)))
;; (define-key map (kbd "g") #'tabulated-list-revert)
;; (define-key map (kbd "q") #'bury-buffer)
(define-key map (kbd "SPC") #'ement-tabulated-room-list-next-unread)
(define-key map (kbd "M-g M-m") #'ement-notify-switch-to-mentions-buffer)
(define-key map (kbd "M-g M-n") #'ement-notify-switch-to-notifications-buffer)
;; (define-key map (kbd "S") #'tabulated-list-sort)
map))
(defvar ement-tabulated-room-list-timestamp-colors nil
"List of colors used for timestamps.
Set automatically when `ement-tabulated-room-list-mode' is activated.")
(defvar ement-sessions)
;;;; Customization
(defgroup ement-tabulated-room-list nil
"Options for the room list buffer."
:group 'ement)
(defcustom ement-tabulated-room-list-auto-update t
"Automatically update the room list buffer."
:type 'boolean)
(defcustom ement-tabulated-room-list-avatars (display-images-p)
"Show room avatars in the room list."
:type 'boolean)
(defcustom ement-tabulated-room-list-simplify-timestamps t
"Only show the largest unit of time in a timestamp.
For example, \"1h54m3s\" becomes \"1h\"."
:type 'boolean)
;;;;; Faces
(defface ement-tabulated-room-list-name
'((t (:inherit font-lock-function-name-face button)))
"Non-direct rooms.")
(defface ement-tabulated-room-list-direct
;; In case `font-lock-constant-face' is bold, we set the weight to normal, so it can be
;; made bold for unread rooms only.
'((t (:weight normal :inherit (font-lock-constant-face ement-tabulated-room-list-name))))
"Direct rooms.")
(defface ement-tabulated-room-list-invited
'((t (:inherit italic ement-tabulated-room-list-name)))
"Invited rooms.")
(defface ement-tabulated-room-list-left
'((t (:strike-through t :inherit ement-tabulated-room-list-name)))
"Left rooms.")
(defface ement-tabulated-room-list-unread
'((t (:inherit bold ement-tabulated-room-list-name)))
"Unread rooms.")
(defface ement-tabulated-room-list-favourite '((t (:inherit (font-lock-doc-face ement-tabulated-room-list-name))))
"Favourite rooms.")
(defface ement-tabulated-room-list-low-priority '((t (:inherit (font-lock-comment-face ement-tabulated-room-list-name))))
"Low-priority rooms.")
(defface ement-tabulated-room-list-recent
'((t (:inherit font-lock-warning-face)))
"Latest timestamp of recently updated rooms.
The foreground color is used to generate a gradient of colors
from recent to non-recent for rooms updated in the past 24
hours but at least one hour ago.")
(defface ement-tabulated-room-list-very-recent
'((t (:inherit error)))
"Latest timestamp of very recently updated rooms.
The foreground color is used to generate a gradient of colors
from recent to non-recent for rooms updated in the past hour.")
;;;; Bookmark support
;; Especially useful with Burly: <https://github.com/alphapapa/burly.el>
(require 'bookmark)
(defun ement-tabulated-room-list-bookmark-make-record ()
"Return a bookmark record for the `ement-tabulated-room-list' buffer."
(pcase-let* (((cl-struct ement-session user) ement-session)
((cl-struct ement-user (id session-id)) user))
;; MAYBE: Support bookmarking specific events in a room.
(list (concat "Ement room list (" session-id ")")
(cons 'session-id session-id)
(cons 'handler #'ement-tabulated-room-list-bookmark-handler))))
(defun ement-tabulated-room-list-bookmark-handler (bookmark)
"Show Ement room list buffer for BOOKMARK."
(pcase-let* (((map session-id) bookmark))
(unless (alist-get session-id ement-sessions nil nil #'equal)
;; MAYBE: Automatically connect.
(user-error "Session %s not connected: call `ement-connect' first" session-id))
(ement-tabulated-room-list)))
;;;; Commands
(defun ement-tabulated-room-list-next-unread ()
"Show next unread room."
(interactive)
(unless (button-at (point))
(call-interactively #'forward-button))
(unless (cl-loop with starting-line = (line-number-at-pos)
if (equal "U" (elt (tabulated-list-get-entry) 0))
do (progn
(goto-char (button-end (button-at (point))))
(push-button (1- (point)))
(cl-return t))
else do (call-interactively #'forward-button)
while (> (line-number-at-pos) starting-line))
;; No more unread rooms.
(message "No more unread rooms")))
;;;###autoload
(defun ement-tabulated-room-list (&rest _ignore)
"Show buffer listing joined rooms.
Calls `pop-to-buffer-same-window'. Interactively, with prefix,
call `pop-to-buffer'."
(interactive)
(with-current-buffer (get-buffer-create "*Ement Rooms*")
(ement-tabulated-room-list-mode)
(setq-local bookmark-make-record-function #'ement-tabulated-room-list-bookmark-make-record)
;; FIXME: There must be a better way to handle this.
(funcall (if current-prefix-arg
#'pop-to-buffer #'pop-to-buffer-same-window)
(current-buffer))))
(defun ement-tabulated-room-list--timestamp-colors ()
"Return a vector of generated latest-timestamp colors for rooms.
Used in `ement-tabulated-room-list' and `ement-room-list'."
(if (or (equal "unspecified-fg" (face-foreground 'default nil 'default))
(equal "unspecified-bg" (face-background 'default nil 'default)))
;; NOTE: On a TTY, the default face's foreground and background colors may be the
;; special values "unspecified-fg"/"unspecified-bg", in which case we can't generate
;; gradients, so we just return a vector of "unspecified-fg". See
;; <https://debbugs.gnu.org/cgi/bugreport.cgi?bug=55623>.
(make-vector 134 "unspecified-fg")
(cl-coerce
(append (mapcar
;; One face per 10-minute period, from "recent" to 1-hour.
(lambda (rgb)
(pcase-let ((`(,r ,g ,b) rgb))
(color-rgb-to-hex r g b 2)))
(color-gradient (color-name-to-rgb (face-foreground 'ement-tabulated-room-list-very-recent
nil 'default))
(color-name-to-rgb (face-foreground 'ement-tabulated-room-list-recent
nil 'default))
6))
(mapcar
;; One face per hour, from "recent" to default.
(lambda (rgb)
(pcase-let ((`(,r ,g ,b) rgb))
(color-rgb-to-hex r g b 2)))
(color-gradient (color-name-to-rgb (face-foreground 'ement-tabulated-room-list-recent
nil 'default))
(color-name-to-rgb (face-foreground 'default nil 'default))
24))
(mapcar
;; One face per week for the last year (actually we
;; generate colors for the past two years' worth so
;; that the face for one-year-ago is halfway to
;; invisible, and we don't use colors past that point).
(lambda (rgb)
(pcase-let ((`(,r ,g ,b) rgb))
(color-rgb-to-hex r g b 2)))
(color-gradient (color-name-to-rgb (face-foreground 'default nil 'default))
(color-name-to-rgb (face-background 'default nil 'default))
104)))
'vector)))
(define-derived-mode ement-tabulated-room-list-mode tabulated-list-mode
"Ement-Tabulated-Room-List"
:group 'ement
(setf tabulated-list-format (vector
'("U" 1 t)
'(#("P" 0 1 (help-echo "Priority (favorite/low)")) 1 t)
'("B" 1 t)
;; '("U" 1 t)
'("d" 1 t) ; Direct
(list (propertize "🐱"
'help-echo "Avatar")
4 t) ; Avatar
'("Name" 25 t) '("Topic" 35 t)
(list "Latest"
(if ement-tabulated-room-list-simplify-timestamps
6 20)
#'ement-tabulated-room-list-latest<
:right-align t)
'("Members" 7 ement-tabulated-room-list-members<)
;; '("P" 1 t) '("Tags" 15 t)
'("Session" 15 t))
tabulated-list-sort-key '("Latest" . t)
ement-tabulated-room-list-timestamp-colors (ement-tabulated-room-list--timestamp-colors))
(add-hook 'tabulated-list-revert-hook #'ement-tabulated-room-list--set-entries nil 'local)
(tabulated-list-init-header)
(ement-tabulated-room-list--set-entries)
(tabulated-list-revert))
(defun ement-tabulated-room-list-action (event)
"Show buffer for room at EVENT or point."
(interactive "e")
(mouse-set-point event)
(pcase-let* ((room (tabulated-list-get-id))
(`[,_unread ,_priority ,_buffer ,_direct ,_avatar ,_name ,_topic ,_latest ,_members ,user-id]
(tabulated-list-get-entry))
(session (alist-get user-id ement-sessions nil nil #'equal)))
(ement-view-room room session)))
;;;; Functions
;;;###autoload
(defun ement-tabulated-room-list-auto-update (_session)
"Automatically update the room list buffer.
Does so when variable `ement-tabulated-room-list-auto-update' is non-nil.
To be called in `ement-sync-callback-hook'."
(when (and ement-tabulated-room-list-auto-update
(buffer-live-p (get-buffer "*Ement Rooms*")))
(with-current-buffer (get-buffer "*Ement Rooms*")
(revert-buffer))))
(defun ement-tabulated-room-list--set-entries ()
"Set `tabulated-list-entries'."
;; Reset avatar size in case default font size has changed.
;; TODO: After implementing avatars.
;; (customize-set-variable 'ement-room-avatar-in-buffer-name-size ement-room-avatar-in-buffer-name-size)
;; NOTE: From Emacs docs:
;; This buffer-local variable specifies the entries displayed in the
;; Tabulated List buffer. Its value should be either a list, or a
;; function.
;;
;; If the value is a list, each list element corresponds to one entry,
;; and should have the form ‘(ID CONTENTS)’, where
;;
;; • ID is either ‘nil’, or a Lisp object that identifies the
;; entry. If the latter, the cursor stays on the same entry when
;; re-sorting entries. Comparison is done with ‘equal’.
;;
;; • CONTENTS is a vector with the same number of elements as
;; ‘tabulated-list-format’. Each vector element is either a
;; string, which is inserted into the buffer as-is, or a list
;; ‘(LABEL . PROPERTIES)’, which means to insert a text button by
;; calling ‘insert-text-button’ with LABEL and PROPERTIES as
;; arguments (*note Making Buttons::).
;;
;; There should be no newlines in any of these strings.
(let ((entries (cl-loop for (_id . session) in ement-sessions
append (mapcar (lambda (room)
(ement-tabulated-room-list--entry session room))
(ement-session-rooms session)))))
(setf tabulated-list-entries
;; Pre-sort by latest event so that, when the list is sorted by other columns,
;; the rooms will be secondarily sorted by latest event.
(cl-sort entries #'> :key (lambda (entry)
;; In case a room has no latest event (not sure if
;; this may obscure a bug, but this has happened, so
;; we need to handle it), we fall back to 0.
(or (ement-room-latest-ts (car entry)) 0))))))
(defun ement-tabulated-room-list--entry (session room)
"Return entry for ROOM in SESSION for `tabulated-list-entries'."
(pcase-let* (((cl-struct ement-room id canonical-alias display-name avatar topic latest-ts summary
(local (map buffer room-list-avatar)))
room)
((map ('m.joined_member_count member-count)) summary)
(e-alias (or canonical-alias
(setf (ement-room-canonical-alias room)
(ement--room-alias room))
id))
;; FIXME: Figure out how to track unread status cleanly.
(e-unread (if (and buffer (buffer-modified-p buffer))
(propertize "U" 'help-echo "Unread") ""))
(e-buffer (if buffer (propertize "B" 'help-echo "Room has buffer") ""))
(e-avatar (if (and ement-tabulated-room-list-avatars avatar)
(or room-list-avatar
(if-let* ((avatar-image (get-text-property 0 'display avatar))
(new-avatar-string (propertize " " 'display
(ement--resize-image avatar-image
nil (frame-char-height)))))
(progn
;; alist-get doesn't seem to return the new value when used with setf?
(setf (alist-get 'room-list-avatar (ement-room-local room))
new-avatar-string)
new-avatar-string)
;; If a room avatar image fails to download or decode
;; and ends up nil, we return the empty string.
(ement-debug "nil avatar for room: " (ement-room-display-name room) (ement-room-canonical-alias room))
""))
;; Room avatars disabled.
""))
;; We have to copy the list, otherwise using `setf' on it
;; later causes its value to be mutated for every entry.
(name-face (cl-copy-list '(:inherit (ement-tabulated-room-list-name))))
(e-name (list (propertize (or display-name
(ement--room-display-name room))
;; HACK: Apply face here, otherwise tabulated-list overrides it.
'face name-face
'help-echo e-alias)
'action #'ement-tabulated-room-list-action))
(e-topic (if topic
;; Remove newlines from topic. Yes, this can happen.
(replace-regexp-in-string "\n" "" topic t t)
""))
(formatted-timestamp (if latest-ts
(ement--human-format-duration (- (time-convert nil 'integer) (/ latest-ts 1000))
t)
""))
(latest-face (when latest-ts
(let* ((difference-seconds (- (float-time) (/ latest-ts 1000)) )
(n (cl-typecase difference-seconds
((number 0 3599) ;; 1 hour to 1 day: 24 1-hour periods.
(truncate (/ difference-seconds 600)))
((number 3600 86400) ;; 1 day
(+ 6 (truncate (/ difference-seconds 3600))))
(otherwise ;; Difference in weeks.
(min (/ (length ement-tabulated-room-list-timestamp-colors) 2)
(+ 24 (truncate (/ difference-seconds 86400 7))))))))
(list :foreground (elt ement-tabulated-room-list-timestamp-colors n)))))
(e-latest (or (when formatted-timestamp
(propertize formatted-timestamp
'value latest-ts
'face latest-face))
;; Invited rooms don't have a latest-ts.
""))
(e-session (propertize (ement-user-id (ement-session-user session))
'value session))
;; ((e-tags favorite-p low-priority-p) (ement-tabulated-room-list--tags room))
(e-direct-p (if (ement--room-direct-p room session)
(propertize "d" 'help-echo "Direct room")
""))
(e-priority (cond ((ement--room-favourite-p room) "F")
((ement--room-low-priority-p room) "l")
(" ")))
(e-members (if member-count (number-to-string member-count) "")))
(when ement-tabulated-room-list-simplify-timestamps
(setf e-latest (replace-regexp-in-string
(rx bos (1+ digit) (1+ alpha) (group (1+ (1+ digit) (1+ alpha))))
"" e-latest t t 1)))
;; Add face modifiers.
(when (and buffer (buffer-modified-p buffer))
;; For some reason, `push' doesn't work with `map-elt'.
(setf (map-elt name-face :inherit)
(cons 'ement-tabulated-room-list-unread (map-elt name-face :inherit))))
(when (ement--room-direct-p room session)
(setf (map-elt name-face :inherit)
(cons 'ement-tabulated-room-list-direct (map-elt name-face :inherit))))
(when (ement--room-favourite-p room)
(push 'ement-tabulated-room-list-favourite (map-elt name-face :inherit)))
(when (ement--room-low-priority-p room)
(push 'ement-tabulated-room-list-low-priority (map-elt name-face :inherit)))
(pcase (ement-room-type room)
('invite
(setf e-topic (concat (propertize "[invited]"
'face 'ement-tabulated-room-list-invited)
" " e-topic)
(map-elt name-face :inherit) (cons 'ement-tabulated-room-list-invited
(map-elt name-face :inherit))))
('leave
(setf e-topic (concat (propertize "[left]"
'face 'ement-tabulated-room-list-left)
" " e-topic)
(map-elt name-face :inherit) (cons (map-elt name-face :inherit)
'ement-tabulated-room-list-left))))
(list room (vector e-unread e-priority e-buffer e-direct-p
e-avatar e-name e-topic e-latest e-members
;; e-tags
e-session
;; e-avatar
))))
;; TODO: Define sorters with a macro? This gets repetitive and hard to update.
(defun ement-tabulated-room-list-members< (a b)
"Return non-nil if entry A has fewer members than room B.
A and B should be entries from `tabulated-list-mode'."
(pcase-let* ((`(,_room [,_unread ,_priority ,_buffer ,_direct ,_avatar ,_name-for-list ,_topic ,_latest ,a-members ,_session]) a)
(`(,_room [,_unread ,_priority ,_buffer ,_direct ,_avatar ,_name-for-list ,_topic ,_latest ,b-members ,_session]) b))
(when (and a-members b-members)
;; Invited rooms may have no member count (I think).
(< (string-to-number a-members) (string-to-number b-members)))))
(defun ement-tabulated-room-list-latest< (a b)
"Return non-nil if entry A has fewer members than room B.
A and B should be entries from `tabulated-list-mode'."
(pcase-let* ((`(,_room-a [,_unread ,_priority ,_buffer ,_direct ,_avatar ,_name-for-list ,_topic ,a-latest ,_a-members ,_session]) a)
(`(,_room-b [,_unread ,_priority ,_buffer ,_direct ,_avatar ,_name-for-list ,_topic ,b-latest ,_b-members ,_session]) b)
(a-latest (get-text-property 0 'value a-latest))
(b-latest (get-text-property 0 'value b-latest)))
(cond ((and a-latest b-latest)
(< a-latest b-latest))
(b-latest
;; Invited rooms have no latest timestamp, and we want to sort them first.
nil)
(t t))))
;;;; Footer
(provide 'ement-tabulated-room-list)
;;; ement-tabulated-room-list.el ends here
;;; ement-structs.el --- Ement structs -*- lexical-binding: t; -*-
;; Copyright (C) 2022 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;;
;;; Code:
;;;; Debugging
;; NOTE: Uncomment this form and `emacs-lisp-byte-compile-and-load' the file to enable
;; `ement-debug' messages. This is commented out by default because, even though the
;; messages are only displayed when `warning-minimum-log-level' is `:debug' at runtime, if
;; that is so at expansion time, the expanded macro calls format the message and check the
;; log level at runtime, which is not zero-cost.
;; (eval-and-compile
;; (setq-local warning-minimum-log-level nil)
;; (setq-local warning-minimum-log-level :debug))
;;;; Requirements
(require 'cl-lib)
;;;; Structs
(cl-defstruct ement-user
id displayname account-data
(color nil :documentation "Color in which to display user's name.")
(message-color nil :documentation "Color in which to display user's messages.")
(username nil
;; NOTE: Not exactly according to spec, I guess, but useful for now.
:documentation "Username part of user's Matrix ID.")
(avatar-url nil :documentation "MXC URL to user's avatar.")
(avatar nil :documentation "One-space string with avatar image in display property."))
(cl-defstruct ement-event
id sender content origin-server-ts type unsigned state-key
receipts
;; The local slot is an alist used by the local client only.
local)
(cl-defstruct ement-server
name uri-prefix)
(cl-defstruct ement-session
user server token transaction-id rooms next-batch
device-id initial-device-display-name has-synced-p
account-data
;; Hash table of all seen events, keyed on event ID.
events)
(cl-defstruct ement-room
id display-name prev-batch
summary state timeline ephemeral account-data unread-notifications
latest-ts topic canonical-alias avatar status type invite-state
(members (make-hash-table :test #'equal) :documentation "Hash table mapping joined user IDs to user structs.")
;; The local slot is an alist used by the local client only.
local
(receipts (make-hash-table :test #'equal))
(displaynames (make-hash-table) :documentation "Hash table mapping users to their displayname in this room."))
;;;; Variables
;;;; Customization
;;;; Commands
;;;; Functions
;;;; Footer
(provide 'ement-structs)
;;; ement-structs.el ends here
;;; ement-room.el --- Ement room buffers -*- lexical-binding: t; -*-
;; Copyright (C) 2022 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; This library implements buffers displaying events in a room.
;; EWOC is a great library. If I had known about it and learned it
;; sooner, it would have saved me a lot of time in other projects.
;; I'm glad I decided to try it for this one.
;;; Code:
;;;; Debugging
;; NOTE: Uncomment this form and `emacs-lisp-byte-compile-and-load' the file to enable
;; `ement-debug' messages. This is commented out by default because, even though the
;; messages are only displayed when `warning-minimum-log-level' is `:debug' at runtime, if
;; that is so at expansion time, the expanded macro calls format the message and check the
;; log level at runtime, which is not zero-cost.
;; (eval-and-compile
;; (setq-local warning-minimum-log-level nil)
;; (setq-local warning-minimum-log-level :debug))
;;;; Requirements
(require 'color)
(require 'ewoc)
(require 'mailcap)
(require 'shr)
(require 'subr-x)
(require 'mwheel)
(require 'dnd)
(require 'ement-api)
(require 'ement-lib)
(require 'ement-macros)
(require 'ement-structs)
;;;; Structs
(cl-defstruct ement-room-membership-events
"Struct grouping membership events.
After adding events, use `ement-room-membership-events--update'
to sort events and update other slots."
(events nil :documentation "Membership events, latest first.")
(earliest-ts nil :documentation "Timestamp of earliest event.")
(latest-ts nil :documentation "Timestamp of latest event."))
(defun ement-room-membership-events--update (struct)
"Return STRUCT having sorted its events and updated its slots."
;; Like the room timeline slot, events are sorted latest-first. We also deduplicate
;; them , because it seems that we can end up with multiple copies of a membership event
;; (e.g. when loading old messages).
(setf (ement-room-membership-events-events struct) (cl-delete-duplicates (ement-room-membership-events-events struct)
:key #'ement-event-id :test #'equal)
(ement-room-membership-events-events struct) (cl-sort (ement-room-membership-events-events struct) #'>
:key #'ement-event-origin-server-ts)
(ement-room-membership-events-earliest-ts struct) (ement-event-origin-server-ts
(car (last (ement-room-membership-events-events struct))))
(ement-room-membership-events-latest-ts struct) (ement-event-origin-server-ts
(car (ement-room-membership-events-events struct))))
struct)
;;;; Variables
(defvar-local ement-ewoc nil
"EWOC for Ement room buffers.")
(defvar-local ement-room nil
"Ement room for current buffer.")
(defvar-local ement-session nil
"Ement session for current buffer.")
(defvar-local ement-room-retro-loading nil
"Non-nil when earlier messages are being loaded.
Used to avoid overlapping requests.")
(defvar-local ement-room-replying-to-event nil
"When non-nil, the user is replying to this event.
Used by `ement-room-send-message'.")
(defvar-local ement-room-replying-to-overlay nil
"Used by `ement-room-write-reply'.")
(defvar-local ement-room-read-receipt-request nil
"Maps event ID to request updating read receipt to that event.
An alist of one entry.")
(defvar ement-room-read-string-setup-hook nil
"Normal hook run by `ement-room-read-string' after switching to minibuffer.
Should be used to, e.g. propagate variables to the minibuffer.")
(defvar ement-room-compose-hook nil
"Hook run in compose buffers when created.
Used to, e.g. call `ement-room-compose-org'.")
(declare-function ement-room-list "ement-room-list.el")
(declare-function ement-notify-switch-to-mentions-buffer "ement-notify")
(declare-function ement-notify-switch-to-notifications-buffer "ement-notify")
(defvar ement-room-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "?") #'ement-room-transient)
;; Movement
(define-key map (kbd "TAB") #'ement-room-goto-next)
(define-key map (kbd "<backtab>") #'ement-room-goto-prev)
(define-key map (kbd "SPC") #'ement-room-scroll-up-mark-read)
(define-key map (kbd "S-SPC") #'ement-room-scroll-down-command)
(define-key map (kbd "M-SPC") #'ement-room-goto-fully-read-marker)
(define-key map [remap scroll-down-command] #'ement-room-scroll-down-command)
(define-key map [remap mwheel-scroll] #'ement-room-mwheel-scroll)
;; Switching
(define-key map (kbd "M-g M-l") #'ement-room-list)
(define-key map (kbd "M-g M-r") #'ement-view-room)
(define-key map (kbd "M-g M-m") #'ement-notify-switch-to-mentions-buffer)
(define-key map (kbd "M-g M-n") #'ement-notify-switch-to-notifications-buffer)
(define-key map (kbd "q") #'quit-window)
;; Messages
(define-key map (kbd "RET") #'ement-room-send-message)
(define-key map (kbd "S-<return>") #'ement-room-write-reply)
(define-key map (kbd "M-RET") #'ement-room-compose-message)
(define-key map (kbd "<insert>") #'ement-room-edit-message)
(define-key map (kbd "C-k") #'ement-room-delete-message)
(define-key map (kbd "s r") #'ement-room-send-reaction)
(define-key map (kbd "s e") #'ement-room-send-emote)
(define-key map (kbd "s f") #'ement-room-send-file)
(define-key map (kbd "s i") #'ement-room-send-image)
(define-key map (kbd "v") #'ement-room-view-event)
;; Users
(define-key map (kbd "u RET") #'ement-send-direct-message)
(define-key map (kbd "u i") #'ement-invite-user)
(define-key map (kbd "u I") #'ement-ignore-user)
;; Room
(define-key map (kbd "M-s o") #'ement-room-occur)
(define-key map (kbd "r d") #'ement-describe-room)
(define-key map (kbd "r m") #'ement-list-members)
(define-key map (kbd "r t") #'ement-room-set-topic)
(define-key map (kbd "r f") #'ement-room-set-message-format)
(define-key map (kbd "r n") #'ement-room-set-notification-state)
(define-key map (kbd "r N") #'ement-room-override-name)
(define-key map (kbd "r T") #'ement-tag-room)
;; Room membership
(define-key map (kbd "R c") #'ement-create-room)
(define-key map (kbd "R j") #'ement-join-room)
(define-key map (kbd "R l") #'ement-leave-room)
(define-key map (kbd "R F") #'ement-forget-room)
(define-key map (kbd "R n") #'ement-room-set-display-name)
(define-key map (kbd "R s") #'ement-room-toggle-space)
;; Other
(define-key map (kbd "g") #'ement-room-sync)
map)
"Keymap for Ement room buffers.")
(defvar ement-room-minibuffer-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map minibuffer-local-map)
(define-key map (kbd "C-c '") #'ement-room-compose-from-minibuffer)
map)
"Keymap used in `ement-room-read-string'.")
(defvar ement-room-sender-in-headers nil
"Non-nil when sender is displayed in headers.
In that case, sender names are aligned to the margin edge.")
(defvar ement-room-messages-filter
'((lazy_load_members . t))
;; NOTE: The confusing differences between what /sync and /messages
;; expect. See <https://github.com/matrix-org/matrix-doc/issues/706>.
"Default RoomEventFilter for /messages requests.")
(defvar ement-room-typing-timer nil
"Timer used to send notifications while typing.")
(defvar ement-room-matrix.to-url-regexp
(rx "http" (optional "s") "://"
"matrix.to" "/#/"
(group (or "!" "#") (1+ (not (any "/"))))
(optional "/" (group "$" (1+ (not (any "?" "/")))))
(optional "?" (group (1+ anything))))
"Regexp matching \"matrix.to\" URLs.")
;; Variables from other files.
(defvar ement-sessions)
(defvar ement-syncs)
(defvar ement-auto-sync)
(defvar ement-users)
(defvar ement-images-queue)
(defvar ement-notify-limit-room-name-width)
(defvar ement-view-room-display-buffer-action)
;; Defined in Emacs 28.1: silence byte-compilation warning in earlier versions.
(defvar browse-url-handlers)
;;;; Customization
(defgroup ement-room nil
"Options for room buffers."
:group 'ement)
(defcustom ement-room-timestamp-header-align 'right
"Where to align timestamp headers."
:type '(choice (const :tag "Left" left)
(const :tag "Center" center)
(const :tag "Right" right)))
(defcustom ement-room-view-hook
'(ement-room-view-hook-room-list-auto-update)
"Functions called when `ement-room-view' is called.
Called with two arguments, the room and the session."
:type 'hook)
;;;;; Faces
(defface ement-room-name
'((t (:inherit font-lock-function-name-face)))
"Room name shown in header line.")
(defface ement-room-membership
'((t (:height 0.8 :inherit font-lock-comment-face)))
"Membership events (join/part).")
(defface ement-room-reactions
'((t (:inherit font-lock-comment-face :height 0.9)))
"Reactions to messages (including the user count).")
(defface ement-room-reactions-key
'((t (:inherit ement-room-reactions :height 1.5)))
"Reactions to messages (the key, i.e. the emoji part).
Uses a separate face to allow the key to be shown at a different
size, because in some fonts, emojis are too small relative to
normal text.")
(defface ement-room-timestamp
'((t (:inherit font-lock-comment-face)))
"Event timestamps.")
(defface ement-room-user
'((t (:inherit font-lock-function-name-face :weight bold :overline t)))
"Usernames.")
(defface ement-room-self
'((t (:inherit (font-lock-variable-name-face ement-room-user) :weight bold)))
"Own username.")
(defface ement-room-message-text
'((t (:inherit default)))
"Text message bodies.")
(defface ement-room-message-emote
'((t (:inherit italic)))
"Emote message bodies.")
(defface ement-room-redacted
'((t (:strike-through t)))
"Redacted messages.")
(defface ement-room-self-message
'((t (:inherit (font-lock-variable-name-face))))
"Oneself's message bodies.
Note that this does not need to inherit
`ement-room-message-text', because that face is combined with
this one automatically.")
(defface ement-room-timestamp-header
'((t (:inherit header-line :weight bold :height 1.1)))
"Timestamp headers.")
(defface ement-room-mention
(if (version< emacs-version "27.1")
'((t (:inherit hl-line)))
'((t (:inherit hl-line :extend t))))
"Messages that mention the local user.")
(defface ement-room-wrap-prefix
`((t :inherit highlight))
"Face applied to `ement-room-wrap-prefix', which see.")
;;;;; Options
(defcustom ement-room-ellipsis "⋮"
"String used when abbreviating certain strings."
:type 'string)
(defcustom ement-room-avatars (display-images-p)
"Show room avatars."
:type 'boolean)
(defcustom ement-room-avatar-max-width 32
"Maximum width in pixels of room avatars shown in header lines."
:type 'integer)
(defcustom ement-room-avatar-max-height 32
"Maximum height in pixels of room avatars shown in header lines."
:type 'integer)
(defcustom ement-room-coalesce-events t
"Coalesce certain events in room buffers.
For example, membership events can be overwhelming in large
rooms, especially ones bridged to IRC. This option groups them
together so they take less space."
:type 'boolean)
(defcustom ement-room-header-line-format
;; TODO: Show in new screenshots.
'(:eval (concat (if ement-room-avatars
(or (ement-room-avatar ement-room)
"")
"")
" " (propertize (ement-room--escape-%
(or (ement-room-display-name ement-room)
"[no room name]"))
'face 'ement-room-name)
": " (propertize (ement-room--escape-%
(or (ement-room-topic ement-room)
"[no topic]"))
;; Also set help-echo in case the topic is too wide to fit.
'help-echo (ement-room-topic ement-room))))
"Header line format for room buffers.
See Info node `(elisp)Header lines'."
:type 'sexp)
(put 'ement-room-header-line-format 'risky-local-variable t)
(defcustom ement-room-buffer-name-prefix "*Ement Room: "
"Prefix for Ement room buffer names."
:type 'string)
(defcustom ement-room-buffer-name-suffix "*"
"Suffix for Ement room buffer names."
:type 'string)
(defcustom ement-room-timestamp-format "%H:%M:%S"
"Format string for event timestamps.
See function `format-time-string'."
:type '(choice (const "%H:%M:%S")
(const "%Y-%m-%d %H:%M:%S")
string))
(defcustom ement-room-left-margin-width 0
"Width of left margin in room buffers.
When using a non-graphical display, this should be set slightly
wider than when using a graphical display, to prevent sender
display names from colliding with event text."
:type 'integer)
(defcustom ement-room-right-margin-width (length ement-room-timestamp-format)
"Width of right margin in room buffers."
:type 'integer)
(defcustom ement-room-sender-headers t
"Show sender headers.
Automatically set by setting `ement-room-message-format-spec',
but may be overridden manually."
:type 'boolean)
(defcustom ement-room-unread-only-counts-notifications t
"Only use notification counts to mark rooms unread.
Notification counts are set by the server based on each room's
notification settings. Otherwise, whether a room is marked
unread depends on the room's fully-read marker, read-receipt
marker, whether the local user sent the latest events, etc."
:type 'boolean)
(defvar ement-room-sender-in-left-margin nil
"Whether sender is shown in left margin.
Set by `ement-room-message-format-spec-setter'.")
(defun ement-room-message-format-spec-setter (option value &optional local)
"Set relevant options for `ement-room-message-format-spec', which see.
To be used as that option's setter. OPTION and VALUE are
received from setting the customization option. If LOCAL is
non-nil, set the variables buffer-locally (i.e. when called from
`ement-room-set-message-format'."
(cl-macrolet ((set-vars (&rest pairs)
;; Set variable-value pairs, locally if LOCAL is non-nil.
`(progn
,@(cl-loop for (symbol value) on pairs by #'cddr
collect `(if local
(set (make-local-variable ',symbol) ,value)
(set ',symbol ,value))))))
(if local
(set (make-local-variable option) value)
(set-default option value))
(pcase value
;; Try to set the margin widths smartly.
("%B%r%R%t" ;; "Elemental"
(set-vars ement-room-left-margin-width 0
ement-room-right-margin-width 8
ement-room-sender-headers t
ement-room-sender-in-headers t
ement-room-sender-in-left-margin nil))
("%S%L%B%r%R%t" ;; "IRC-style using margins"
(set-vars ement-room-left-margin-width 12
ement-room-right-margin-width 8
ement-room-sender-headers nil
ement-room-sender-in-headers nil
ement-room-sender-in-left-margin t))
("[%t] %S> %B%r" ;; "IRC-style without margins"
(set-vars ement-room-left-margin-width 0
ement-room-right-margin-width 0
ement-room-sender-headers nil
ement-room-sender-in-headers nil
ement-room-sender-in-left-margin nil))
(_ (set-vars ement-room-left-margin-width
(if (string-match-p "%L" value)
12 0)
ement-room-right-margin-width
(if (string-match-p "%R" value)
8 0)
ement-room-sender-in-left-margin
(if (string-match-p (rx (1+ anything) (or "%S" "%s") (1+ anything) "%L") value)
t nil)
;; NOTE: The following two variables may seem redundant, but one is an
;; option that the user may override, while the other is set
;; automatically.
ement-room-sender-headers
(if (string-match-p (or "%S" "%s") value)
;; If "%S" or "%s" isn't found, assume it's to be shown in headers.
nil t)
ement-room-sender-in-headers
(if (string-match-p (rx (or "%S" "%s")) value)
;; If "%S" or "%s" isn't found, assume it's to be shown in headers.
nil t))
(message "Ement: When using custom message format, setting margin widths may be necessary")))
(unless ement-room-sender-in-headers
;; HACK: Disable overline on sender face.
(require 'face-remap)
(if local
(progn
(face-remap-reset-base 'ement-room-user)
(face-remap-add-relative 'ement-room-user '(:overline nil)))
(set-face-attribute 'ement-room-user nil :overline nil)))
(unless local
(when (and (bound-and-true-p ement-sessions) (car ement-sessions))
;; Only display when a session is connected (not sure why `bound-and-true-p'
;; is required to avoid compilation warnings).
(message "Ement: Kill and reopen room buffers to display in new format")))))
(defcustom ement-room-message-format-spec "%S%L%B%r%R%t"
"Format messages according to this spec.
It may contain these specifiers:
%L End of left margin
%R Start of right margin
%W End of wrap-prefix
%b Message body (plain-text)
%B Message body (formatted if available)
%i Event ID
%O Room display name (used for mentions buffer)
%r Reactions
%s Sender ID
%S Sender display name
%t Event timestamp, formatted according to
`ement-room-timestamp-format'
Note that margin sizes must be set manually with
`ement-room-left-margin-width' and
`ement-room-right-margin-width'."
:type '(choice (const :tag "IRC-style using margins" "%S%L%B%r%R%t")
(const :tag "IRC-style without margins" "[%t] %S> %B%r")
(const :tag "IRC-style without margins, with wrap-prefix" "[%t] %S> %W%B%r")
(const :tag "IRC-style with right margin, with wrap-prefix" "%S> %W%B%r%R%t")
(const :tag "Elemental" "%B%r%R%t")
(string :tag "Custom format"))
:set #'ement-room-message-format-spec-setter
:set-after '(ement-room-left-margin-width ement-room-right-margin-width
ement-room-sender-headers)
;; This file must be loaded before calling the setter to define the
;; `ement-room-user' face used in it.
:require 'ement-room)
(defcustom ement-room-retro-messages-number 30
"Number of messages to retrieve when loading earlier messages."
:type 'integer)
(defcustom ement-room-timestamp-header-format " %H:%M "
"Format string for timestamp headers where date is unchanged.
See function `format-time-string'. If this string ends in a
newline, its background color will extend to the end of the
line."
:type '(choice (const :tag "Time-only" " %H:%M ")
(const :tag "Always show date" " %Y-%m-%d %H:%M ")
string))
(defcustom ement-room-timestamp-header-with-date-format " %Y-%m-%d (%A)\n"
;; FIXME: In Emacs 27+, maybe use :extend t instead of adding a newline.
"Format string for timestamp headers where date changes.
See function `format-time-string'. If this string ends in a
newline, its background color will extend to the end of the
line."
:type '(choice (const " %Y-%m-%d (%A)\n")
string))
(defcustom ement-room-replace-edited-messages t
"Replace edited messages with their new content.
When nil, edited messages are displayed as new messages, leaving
the original messages visible."
:type 'boolean)
(defcustom ement-room-shr-use-fonts nil
"Enable `shr' variable-pitch fonts for formatted bodies.
If non-nil, `shr' may use variable-pitch fonts for formatted
bodies (which include most replies), which means that some
messages won't display in the same font as others."
:type '(choice (const :tag "Disable variable-pitch fonts" nil)
(const :tag "Enable variable-pitch fonts" t)))
(defcustom ement-room-username-display-property '(raise -0.25)
"Display property applied to username strings.
See Info node `(elisp)Other Display Specs'."
:type '(choice (list :tag "Raise" (const raise :tag "Raise") (number :tag "Factor"))
(list :tag "Height" (const height)
(choice (list :tag "Larger" (const + :tag "Larger") (number :tag "Steps"))
(list :tag "Smaller" (const - :tag "Smaller") (number :tag "Steps"))
(number :tag "Factor")
(function :tag "Function")
(sexp :tag "Form"))) ))
(defcustom ement-room-event-separator-display-property '(space :ascent 50)
"Display property applied to invisible space string after events.
Allows visual separation between events without, e.g. inserting
newlines.
See Info node `(elisp)Specified Space'."
:type 'sexp)
(defcustom ement-room-timestamp-header-delta 600
"Show timestamp header where events are at least this many seconds apart."
:type 'integer)
(defcustom ement-room-send-message-filter nil
"Function through which to pass message content before sending.
Used to, e.g. send an Org-formatted message by exporting it to
HTML first."
:type '(choice (const :tag "Send messages as-is" nil)
(const :tag "Send messages in Org format" ement-room-send-org-filter)
(function :tag "Custom filter function"))
:set (lambda (option value)
(set-default option value)
(pcase value
('ement-room-send-org-filter
;; Activate in compose buffer by default.
(add-hook 'ement-room-compose-hook #'ement-room-compose-org))
(_ (remove-hook 'ement-room-compose-hook #'ement-room-compose-org)))))
(defcustom ement-room-mark-rooms-read t
"Mark rooms as read automatically.
Moves read and fully-read markers in rooms on the server when
`ement-room-scroll-up-mark-read' is called at the end of a
buffer. When `send', also marks room as read when sending a
message in it. When disabled, rooms may still be marked as read
manually by calling `ement-room-mark-read'. Note that this is
not strictly the same as read receipts."
:type '(choice (const :tag "When scrolling past end of buffer" t)
(const :tag "Also when sending" send)
(const :tag "Never" nil)))
(defcustom ement-room-send-typing t
"Send typing notifications to the server while typing a message."
:type 'boolean)
(defcustom ement-room-join-view-buffer t
"View room buffer when joining a room."
:type 'boolean)
(defcustom ement-room-leave-kill-buffer t
"Kill room buffer when leaving a room.
When disabled, the room's buffer will remain open, but
Matrix-related commands in it will fail."
:type 'boolean)
(defcustom ement-room-warn-for-already-seen-messages nil
"Warn when a sent message has already been seen.
Such a case could very rarely indicate a reused transaction ID,
which would prevent further messages from being sent (and would
be solved by logging in with a new session, generating a new
token), but most often it happens when the server echoes back a
sent message before acknowledging the sending of the
message (which is harmless and can be ignored)."
:type 'boolean)
(defcustom ement-room-wrap-prefix
(concat (propertize " "
'face 'ement-room-wrap-prefix)
" ")
"String prefixing certain events in room buffers.
Events include membership events, image attachments, etc.
Generally users should prefer to customize the face
`ement-room-wrap-prefix' rather than this option, because this
option's default value has that face applied to it where
appropriate; if users customize this option, they will need to
apply the face to the string themselves, if desired."
:type 'string)
(defgroup ement-room-prism nil
"Colorize usernames and messages in rooms."
:group 'ement-room)
(defcustom ement-room-prism 'name
"Display users' names and messages in unique colors."
:type '(choice (const :tag "Name only" name)
(const :tag "Name and message" both)
(const :tag "Neither" nil)))
(defcustom ement-room-prism-addressee t
"Show addressees' names in their respective colors.
Applies to room member names at the beginning of messages,
preceded by a colon or comma.
Note that a limitation applies to the current implementation: if
a message from the addressee is not yet visible in a room at the
time the addressed message is formatted, the color may not be
applied."
;; FIXME: When we keep a hash table of members in a room, make this
;; smarter.
:type 'boolean)
(defcustom ement-room-prism-color-adjustment 0
"Number used to tweak computed username colors.
This may be used to adjust your favorite users' colors if you
don't like the default ones. (The only way to do it is by
experimentation--there is no direct mapping available, nor a
per-user setting.)
The number is added to the hashed user ID before converting it to
a color. Note that, since user ID hashes are ratioed against
`most-positive-fixnum', this number must be very large in order
to have any effect; it should be at least 1e13.
After changing this option, a room's buffer must be killed and
recreated to see the effect."
:type 'number
:set (lambda (option value)
(unless (or (= 0 value) (>= value 1e13))
(user-error "This option must be a very large number, at least 1e13"))
(set-default option value)))
(defcustom ement-room-prism-minimum-contrast 6
"Attempt to enforce this minimum contrast ratio for user faces.
This should be a reasonable number from, e.g. 0-7 or so."
;; Prot would almost approve of this default. :) I would go all the way
;; to 7, but 6 already significantly dilutes the colors in some cases.
:type 'number)
(defcustom ement-room-prism-message-desaturation 25
"Desaturate user colors by this percent for message bodies.
Makes message bodies a bit less intense."
:type 'integer)
(defcustom ement-room-prism-message-lightening 10
"Lighten user colors by this percent for message bodies.
Makes message bodies a bit less intense.
When using a light theme, it may be necessary to use a negative
number (to darken rather than lighten)."
:type 'integer)
;;;; Macros
(defmacro ement-room-with-highlighted-event-at (position &rest body)
"Highlight event at POSITION while evaluating BODY."
;; MAYBE: Accept a marker for POSITION.
(declare (indent 1))
`(let* ((node (ewoc-locate ement-ewoc ,position))
(event (ewoc-data node))
ement-room-replying-to-event ement-room-replying-to-overlay)
(unless (and (ement-event-p event)
(ement-event-id event))
(error "No event at point"))
(unwind-protect
(progn
(setf ement-room-replying-to-event event
ement-room-replying-to-overlay
(make-overlay (ewoc-location node)
;; NOTE: It doesn't seem possible to get the end position of
;; a node, so if there is no next node, we use point-max.
;; But this might break if we were to use an EWOC footer.
(if (ewoc-next ement-ewoc node)
(ewoc-location (ewoc-next ement-ewoc node))
(point-max))))
(overlay-put ement-room-replying-to-overlay 'face 'highlight)
,@body)
(when (overlayp ement-room-replying-to-overlay)
(delete-overlay ement-room-replying-to-overlay))
(setf ement-room-replying-to-event nil
ement-room-replying-to-overlay nil))))
(defmacro ement-room-with-typing (&rest body)
"Send typing notifications around BODY.
When `ement-room-send-typing' is enabled, typing notifications
are sent while BODY is executing. BODY is wrapped in an
`unwind-protect' form that cancels `ement-room-typing-timer' and
sends a not-typing notification."
(declare (indent defun))
`(unwind-protect
(progn
(when ement-room-send-typing
(when ement-room-typing-timer
;; In case there are any stray ones (e.g. a user typing in
;; more than room at once, which is possible but unlikely).
(cancel-timer ement-room-typing-timer))
(setf ement-room-typing-timer (run-at-time nil 15 #'ement-room--send-typing ement-session ement-room)))
,@body)
(when ement-room-send-typing
(when ement-room-typing-timer
(cancel-timer ement-room-typing-timer)
(setf ement-room-typing-timer nil))
;; Cancel typing notifications after sending a message. (The
;; spec doesn't say whether this is needed, but it seems to be.)
(ement-room--send-typing ement-session ement-room :typing nil))))
(defmacro ement-room-wrap-prefix (string-form &rest properties)
"Wrap STRING-FORM with `ement-room-wrap-prefix'.
Concats `ement-room-wrap-prefix' to STRING-FORM and applies it as
the `wrap-prefix' property. Also applies any PROPERTIES."
(declare (indent defun))
`(concat ement-room-wrap-prefix
(propertize ,string-form
'wrap-prefix ement-room-wrap-prefix
,@properties)))
(defsubst ement-room--concat-property (string property value &optional append)
"Return STRING having concatted VALUE with PROPERTY on it.
If APPEND, append it; otherwise prepend. Assumes PROPERTY is
constant throughout STRING."
(declare (indent defun))
(let* ((old-value (get-text-property 0 property string))
(new-value (if append
(concat old-value value)
(concat value old-value))))
(propertize string property new-value)))
;;;;; Event formatting
;; NOTE: When adding specs, also add them to docstring
;; for `ement-room-message-format-spec'.
(defvar ement-room-event-formatters nil
"Alist mapping characters to event-formatting functions.
Each function is called with three arguments: the event, the
room, and the session. See macro
`ement-room-define-event-formatter'.")
(defvar ement-room--format-message-margin-p nil
"Set by margin-related event formatters.")
(defvar ement-room--format-message-wrap-prefix nil
"Set by margin-related event formatters.")
(defmacro ement-room-define-event-formatter (char docstring &rest body)
"Define an event formatter for CHAR with DOCSTRING and BODY.
BODY is wrapped in a lambda form that binds `event', `room', and
`session', and the lambda is added to the variable
`ement-room-event-formatters', which see."
(declare (indent defun))
`(setf (alist-get ,char ement-room-event-formatters nil nil #'equal)
(lambda (event room session)
,docstring
,@body)))
(ement-room-define-event-formatter ?L
"Text before this is shown in the left margin."
(ignore event room session)
(setf ement-room--format-message-margin-p t)
(propertize " " 'left-margin-end t))
(ement-room-define-event-formatter ?R
"Text after this is shown in the right margin."
(ignore event room session)
(setf ement-room--format-message-margin-p t)
(propertize " " 'right-margin-start t))
(ement-room-define-event-formatter ?W
"Text before this is the length of the event's wrap-prefix.
This emulates the effect of using the left margin (the \"%L\"
spec) without requiring all events to use the same margin width."
(ignore event room session)
(setf ement-room--format-message-wrap-prefix t)
(propertize " " 'wrap-prefix-end t))
(ement-room-define-event-formatter ?b
"Plain-text body content."
;; NOTE: `save-match-data' is required around calls to `ement-room--format-message-body'.
(let ((body (save-match-data
(ement-room--format-message-body event :formatted-p nil)))
(face (ement-room--event-body-face event room session)))
(add-face-text-property 0 (length body) face 'append body)
(when ement-room-prism-addressee
(ement-room--add-member-face body room))
body))
(ement-room-define-event-formatter ?B
"Formatted body content (i.e. rendered HTML)."
(let ((body (save-match-data
(ement-room--format-message-body event)))
(face (ement-room--event-body-face event room session)))
(add-face-text-property 0 (length body) face 'append body)
(when ement-room-prism-addressee
(ement-room--add-member-face body room))
body))
(ement-room-define-event-formatter ?i
"Event ID."
;; Probably only useful for debugging, so might remove later.
(ignore room session)
(ement-event-id event))
(ement-room-define-event-formatter ?o
"Room avatar."
(ignore event session)
(or (alist-get 'room-list-avatar (ement-room-local room)) ""))
(ement-room-define-event-formatter ?O
"Room display name."
(ignore event session)
(let ((room-name (propertize (or (ement-room-display-name room)
(ement--room-display-name room))
'face 'ement-room-name
'help-echo (or (ement-room-canonical-alias room)
(ement-room-id room)))))
;; HACK: This will probably only be used in the notifications buffers, anyway.
(when ement-notify-limit-room-name-width
(setf room-name (truncate-string-to-width room-name ement-notify-limit-room-name-width
nil nil ement-room-ellipsis)))
room-name))
;; NOTE: In ?s and ?S, we add nearly-invisible ASCII unit-separator characters ("")
;; to prevent, e.g. `dabbrev-expand' from expanding display names with body text.
(ement-room-define-event-formatter ?s
"Sender MXID."
(ignore room session)
(concat (propertize (ement-user-id (ement-event-sender event))
'face 'ement-room-user)
""))
(ement-room-define-event-formatter ?S
"Sender display name."
(ignore session)
(pcase-let ((sender (ement--format-user (ement-event-sender event) room))
((cl-struct ement-room (local (map buffer))) room))
;; NOTE: When called from an `ement-notify' function, ROOM may have no buffer. In
;; that case, just use the current buffer (which should be a temp buffer used to
;; format the event).
(with-current-buffer (or buffer (current-buffer))
(when ement-room-sender-in-left-margin
;; Sender in left margin: truncate/pad appropriately.
(setf sender
(if (< (string-width sender) ement-room-left-margin-width)
;; Using :align-to or :width space display properties doesn't
;; seem to have any effect in the margin, so we make a string.
(concat (make-string (- ement-room-left-margin-width (string-width sender))
? )
sender)
;; String wider than margin: truncate it.
(ement-room--concat-property
(truncate-string-to-width sender ement-room-left-margin-width nil nil "…")
'help-echo (concat sender " "))))))
;; NOTE: I'd like to add a help-echo function to display the sender ID, but the Emacs
;; manual says that there is currently no way to make text in the margins mouse-sensitive.
;; So `ement--format-user' returns a string propertized with `help-echo' as a string.
(concat sender "")))
(ement-room-define-event-formatter ?r
"Reactions."
(ignore room session)
(ement-room--format-reactions event))
(ement-room-define-event-formatter ?t
"Timestamp."
(ignore room session)
(propertize (format-time-string ement-room-timestamp-format ;; Timestamps are in milliseconds.
(/ (ement-event-origin-server-ts event) 1000))
'face 'ement-room-timestamp
'help-echo (format-time-string "%Y-%m-%d %H:%M:%S"
(/ (ement-event-origin-server-ts event) 1000))))
(defun ement-room--event-body-face (event room session)
"Return face definition for EVENT in ROOM on SESSION."
(ignore room) ;; Unused for now, but keeping for consistency.
;; This used to be a macro in --format-message, which is probably better for
;; performance, but using a function is clearer, and avoids premature optimization.
(pcase-let* (((cl-struct ement-event sender
(content (map msgtype))
(unsigned (map ('redacted_by unsigned-redacted-by)))
(local (map ('redacted-by local-redacted-by))))
event)
((cl-struct ement-user (id sender-id)) sender)
((cl-struct ement-session user) session)
((cl-struct ement-user (id user-id)) user)
(self-message-p (equal sender-id user-id))
(type-face (pcase msgtype
("m.emote" 'ement-room-message-emote)
(_ 'ement-room-message-text)))
(context-face (cond (self-message-p
'ement-room-self-message)
((or (ement-room--event-mentions-user-p event user)
(ement--event-mentions-room-p event))
'ement-room-mention)))
(prism-color (unless self-message-p
(when (eq 'both ement-room-prism)
(or (ement-user-message-color sender)
(setf (ement-user-message-color sender)
(let ((message-color (color-desaturate-name (ement--user-color sender)
ement-room-prism-message-desaturation)))
(if (ement--color-dark-p (color-name-to-rgb (face-background 'default)))
(color-lighten-name message-color ement-room-prism-message-lightening)
(color-darken-name message-color ement-room-prism-message-lightening))))))))
(redacted-face (when (or local-redacted-by unsigned-redacted-by)
'ement-room-redacted))
(body-face (list :inherit (delq nil (list redacted-face context-face type-face)))))
(if prism-color
(plist-put body-face :foreground prism-color)
body-face)))
(defun ement-room--add-member-face (string room)
"Add member faces in ROOM to STRING.
If STRING begins with the name of a member in ROOM followed by a
colon or comma (as if STRING is a message addressing that
member), apply that member's displayname color face to that part
of the string.
Note that, if ROOM has no buffer, STRING is returned unchanged."
;; This only looks for a member name at the beginning of the string. It would be neat to add
;; colors to every member mentioned in a message, but that would probably not perform well.
;; NOTE: This function may be called by `ement-notify' functions even when the room has
;; no buffer, and this function is designed to use events in a room buffer to more
;; quickly find the data it needs, so, for now, if the room has no buffer, we return
;; STRING unchanged.
(pcase-let (((cl-struct ement-room (local (map buffer))) room))
(if (buffer-live-p buffer)
(save-match-data
;; This function may be called from a chain of others that use the match data, so
;; rather than depending on all of them to save the match data, we do it here.
;; FIXME: Member names containing spaces aren't matched. Can this even be fixed reasonably?
(when (string-match (rx bos (group (1+ (not blank))) (or ":" ",") (1+ blank)) string)
(when-let* ((member-name (match-string 1 string))
;; HACK: Since we don't currently keep a list of all
;; members in a room, we look to see if this displayname
;; has any mentions in the room so far.
(user (save-match-data
(with-current-buffer buffer
(save-excursion
(goto-char (point-min))
(cl-labels ((found-sender-p
(ewoc-data)
(when (ement-event-p ewoc-data)
(equal member-name
(gethash (ement-event-sender ewoc-data) (ement-room-displaynames room))))))
(cl-loop with regexp = (regexp-quote member-name)
while (re-search-forward regexp nil t)
;; NOTE: I don't know why, but sometimes the regexp
;; search ends on a non-event line, like a timestamp
;; header, so for now we just try to handle that case.
for maybe-event = (ewoc-data (ewoc-locate ement-ewoc))
when (found-sender-p maybe-event)
return (ement-event-sender maybe-event)))))))
(prism-color (or (ement-user-color user)
(setf (ement-user-color user)
(ement-room--user-color user)))))
(add-face-text-property (match-beginning 1) (match-end 1)
(list :foreground prism-color) nil string))))
;; Room has no buffer: return STRING as-is.
string)))
;;;; Bookmark support
;; Especially useful with Burly: <https://github.com/alphapapa/burly.el>
(require 'bookmark)
(defun ement-room-bookmark-make-record ()
"Return a bookmark record for the current `ement-room' buffer."
(pcase-let* (((cl-struct ement-room (id room-id) canonical-alias display-name) ement-room)
((cl-struct ement-session user) ement-session)
((cl-struct ement-user (id session-id)) user))
;; MAYBE: Support bookmarking specific events in a room.
(list (concat "Ement room: " display-name " (" canonical-alias ")")
(cons 'session-id session-id)
(cons 'room-id room-id)
(cons 'handler #'ement-room-bookmark-handler))))
(defun ement-room-bookmark-handler (bookmark)
"Show Ement room buffer for BOOKMARK."
(pcase-let* ((`(,_name . ,(map session-id room-id)) bookmark)
(session (ement-aprog1
(alist-get session-id ement-sessions nil nil #'equal)
(unless it
;; MAYBE: Automatically connect.
(user-error "Session %s not connected: call `ement-connect' first" session-id))))
(room (ement-aprog1
(ement-afirst (equal room-id (ement-room-id it))
(ement-session-rooms session))
(cl-assert it nil "Room %S not found on session %S" room-id session-id))))
(ement-view-room room session)
;; HACK: Put point at the end of the room buffer. This seems unusually difficult,
;; apparently because the bookmark library itself moves point after jumping to a
;; bookmark. My attempts at setting the buffer's and window's points after calling
;; `ement-view-room' have had no effect. `bookmark-after-jump-hook' sounds ideal, but
;; it does not seem to actually get run, so we use a timer that runs immediately after
;; `bookmark-jump' returns.
(run-at-time nil nil (lambda ()
(goto-char (point-max))))))
;;;; Commands
(defun ement-room-override-name (name room session)
"Set display NAME override for ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room. If NAME is the empty string, remove
the override.
Sets account-data event of type
\"org.matrix.msc3015.m.room.name.override\". This name is only
used by clients that respect this proposed override. See
<https://github.com/matrix-org/matrix-spec-proposals/pull/3015#issuecomment-1451017296>."
(interactive
(ement-with-room-and-session
(let* ((prompt (format "Set name override (%s): " (ement--format-room ement-room)))
(name (read-string prompt nil nil (ement-room-display-name ement-room))))
(list name ement-room ement-session))))
(ement-put-account-data session "org.matrix.msc3015.m.room.name.override"
(if (string-empty-p name)
;; `json-encode' wants an empty hash table to represent an empty map. And
;; apparently there's no way to DELETE account-data events, so we have to re-PUT
;; it with empty content.
(make-hash-table)
(ement-alist "name" name))
:room room))
(defun ement-room-flush-colors ()
"Flush generated username/message colors.
Also, redisplay events in all open buffers. The colors will be
regenerated according to the current background color. Helpful
when switching themes or adjusting `ement-prism' options."
(interactive)
(cl-loop for user being the hash-values of ement-users
do (setf (ement-user-color user) nil
(ement-user-message-color user) nil))
(dolist (buffer (buffer-list))
(when (eq 'ement-room-mode (buffer-local-value 'major-mode buffer))
(with-current-buffer buffer
(ewoc-refresh ement-ewoc))))
;; Flush notify-background-color colors.
(cl-loop for (_id . session) in ement-sessions
do (cl-loop for room in (ement-session-rooms session)
do (setf (alist-get 'notify-background-color (ement-room-local room)) nil)))
;; NOTE: The notifications buffer can't be refreshed because each event is from a
;; different room, and the `ement-room' variable is unset in the buffer.
;; (when-let (buffer (get-buffer "*Ement Notifications*"))
;; (with-current-buffer buffer
;; (ewoc-refresh ement-ewoc)))
)
(defun ement-room-browse-url (url &rest args)
"Browse URL, using Ement for matrix.to URLs when possible.
Otherwise, fall back to `browse-url'. When called outside of an
`ement-room' buffer, the variable `ement-session' must be bound
to the session in which to look for URL's room and event. ARGS
are passed to `browse-url'."
(interactive)
(when (string-match ement-room-matrix.to-url-regexp url)
(let* ((room-id (when (string-prefix-p "!" (match-string 1 url))
(match-string 1 url)))
(room-alias (when (string-prefix-p "#" (match-string 1 url))
(match-string 1 url)))
(event-id (match-string 2 url))
(room (when (or
;; Compare with current buffer's room.
(and room-id (equal room-id (ement-room-id ement-room)))
(and room-alias (equal room-alias (ement-room-canonical-alias ement-room)))
;; Compare with other rooms on session.
(and room-id (cl-find room-id (ement-session-rooms ement-session)
:key #'ement-room-id))
(and room-alias (cl-find room-alias (ement-session-rooms ement-session)
:key #'ement-room-canonical-alias)))
ement-room)))
(if room
(progn
;; Found room in current session: view it and find the event.
(ement-view-room room ement-session)
(when event-id
(ement-room-find-event event-id)))
;; Room not joined: offer to join it or load link in browser.
(pcase-exhaustive (completing-read
(format "Room <%s> not joined on current session. Join it, or load link with browser?"
(or room-alias room-id))
'("Join room" "Load link with browser") nil t)
("Join room" (ement-join-room (or room-alias room-id) ement-session
:then (when event-id
(lambda (room session)
(ement-view-room room session)
(ement-room-find-event event-id)))))
("Load link with browser" (apply #'browse-url url args)))))))
(defun ement-room-find-event (event-id)
"Go to EVENT-ID in current buffer."
(interactive)
(cl-labels ((goto-event
(event-id) (progn
(push-mark)
(goto-char
(ewoc-location
(ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(and (ement-event-p data)
(equal event-id (ement-event-id data))))))))))
(if (or (cl-find event-id (ement-room-timeline ement-room)
:key #'ement-event-id :test #'equal)
(cl-find event-id (ement-room-state ement-room)
:key #'ement-event-id :test #'equal))
;; Found event in timeline: it should be in the EWOC, so go to it.
(goto-event event-id)
;; Event not found in timeline: try to retro-load it.
(message "Event %s not seen in current room. Looking in history..." event-id)
(let ((room ement-room))
(ement-room-retro-to ement-room ement-session event-id
;; TODO: Add an ELSE argument to `ement-room-retro-to' and use it to give
;; a useful error here.
:then (lambda ()
(with-current-buffer (alist-get 'buffer (ement-room-local room))
(goto-event event-id))))))))
(defun ement-room-set-composition-format (&optional localp)
"Set message composition format.
If LOCALP (interactively, with prefix), set in current room's
buffer. Sets `ement-room-send-message-filter'."
(interactive (list current-prefix-arg))
(let* ((formats (list (cons "Plain-text" nil)
(cons "Org-mode" #'ement-room-send-org-filter)))
(selected-name (completing-read "Composition format: " formats nil 'require-match nil nil
ement-room-send-message-filter))
(selected-filter (alist-get selected-name formats nil nil #'equal)))
(if localp
(setq-local ement-room-send-message-filter selected-filter)
(setq ement-room-send-message-filter selected-filter))))
(defun ement-room-set-message-format (format-spec)
"Set `ement-room-message-format-spec' in current buffer to FORMAT-SPEC.
Interactively, prompts for the spec using suggested values of the
option."
(interactive (list (let* ((choices (thread-last
(get 'ement-room-message-format-spec 'custom-type)
cdr
(seq-filter (lambda (it)
(eq (car it) 'const)))
(mapcar (lambda (it)
(cons (nth 2 it) (nth 3 it))))))
(choice (completing-read "Format: " (mapcar #'car choices))))
(or (alist-get choice choices nil nil #'equal)
choice))))
(cl-assert ement-ewoc)
(ement-room-message-format-spec-setter 'ement-room-message-format-spec format-spec 'local)
(setf left-margin-width ement-room-left-margin-width
right-margin-width ement-room-right-margin-width)
(set-window-margins nil left-margin-width right-margin-width)
(if ement-room-sender-in-headers
(ement-room--insert-sender-headers ement-ewoc)
(ewoc-filter ement-ewoc (lambda (node-data)
;; Return non-nil for nodes that should stay.
(not (ement-user-p node-data)))))
(ewoc-refresh ement-ewoc))
(defun ement-room-set-topic (session room topic)
"Set ROOM's TOPIC on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room."
(interactive
(ement-with-room-and-session
(list ement-session ement-room
(read-string (format "New topic (%s): "
(ement-room-display-name ement-room))
(ement-room-topic ement-room) nil nil 'inherit-input-method))))
(pcase-let* (((cl-struct ement-room (id room-id) display-name) room)
(endpoint (format "rooms/%s/state/m.room.topic" (url-hexify-string room-id)))
(data (ement-alist "topic" topic)))
(ement-api session endpoint :method 'put :data (json-encode data)
:then (lambda (_data)
(message "Topic set (%s): %s" display-name topic)))))
(cl-defun ement-room-send-file (file body room session &key (msgtype "m.file"))
"Send FILE to ROOM on SESSION, using message BODY and MSGTYPE.
Interactively, with prefix, prompt for room and session,
otherwise use current room."
;; TODO: Support URLs to remote files.
(interactive
(ement-with-room-and-session
(ement-room-with-typing
(let* ((file (read-file-name (format "Send file (%s): " (ement-room-display-name ement-room))
nil nil 'confirm))
(body (ement-room-read-string (format "Message body (%s): " (ement-room-display-name ement-room))
(file-name-nondirectory file) nil nil 'inherit-input-method)))
(list file body ement-room ement-session)))))
;; NOTE: The typing notification won't be quite right, because it'll be canceled while waiting
;; for the file to upload. It would be awkward to handle that, so this will do for now.
(when (yes-or-no-p (format "Upload file %S to room %S? "
file (ement-room-display-name room)))
(pcase-let* ((filename (file-name-nondirectory file))
(extension (or (file-name-extension file) ""))
(mime-type (mailcap-extension-to-mime extension))
(data (with-temp-buffer
;; NOTE: Using (set-buffer-multibyte nil) doesn't
;; seem to be necessary, but I don't know why not.
(insert-file-contents file)
(buffer-string)))
(size (length data)))
(ement-upload session :data data :filename filename :content-type mime-type
:then (lambda (data)
(message "Uploaded file %S. Sending message..." file)
(pcase-let* (((map ('content_uri content-uri)) data)
((cl-struct ement-room (id room-id)) room)
(endpoint (format "rooms/%s/send/%s/%s" (url-hexify-string room-id)
"m.room.message" (ement--update-transaction-id session)))
;; TODO: Image height/width (maybe not easy to get in Emacs).
(content (ement-alist "msgtype" msgtype
"url" content-uri
"body" body
"filename" filename
"info" (ement-alist "mimetype" mime-type
"size" size))))
(ement-api session endpoint :method 'put :data (json-encode content)
:then (apply-partially #'ement-room-send-event-callback
:room room :session session :content content :data))))))))
(defun ement-room-send-image (file body room session)
"Send image FILE to ROOM on SESSION, using message BODY.
Interactively, with prefix, prompt for room and session,
otherwise use current room."
;; TODO: Support URLs to remote files.
(interactive
(ement-with-room-and-session
(ement-room-with-typing
(let* ((file (read-file-name (format "Send image file (%s): " (ement-room-display-name ement-room))
nil nil 'confirm))
(body (ement-room-read-string (format "Message body (%s): " (ement-room-display-name ement-room))
(file-name-nondirectory file) nil nil 'inherit-input-method)))
(list file body ement-room ement-session)))))
(ement-room-send-file file body room session :msgtype "m.image"))
(defun ement-room-dnd-upload-file (uri _action)
"Upload the file as specified by URI to the current room."
(when-let ((file (dnd-get-local-file-name uri t)))
(ement-room-send-file file (file-name-nondirectory file) ement-room ement-session
:msgtype (if (string-prefix-p "image/" (mailcap-file-name-to-mime-type file))
"m.image"
"m.file"))))
(declare-function ement-tabulated-room-list-next-unread "ement-tabulated-room-list")
(declare-function ement-room-list-next-unread "ement-room-list")
(defun ement-room-scroll-up-mark-read ()
"Scroll buffer up, marking read and burying when at end."
(interactive)
(if (= (window-point) (point-max))
(progn
;; At the bottom of the buffer: mark read and show next unread room.
(when ement-room-mark-rooms-read
(ement-room-mark-read ement-room ement-session
:read-event (ewoc-data (ement-room--ewoc-last-matching ement-ewoc
(lambda (data) (ement-event-p data))))
:fully-read-event (ewoc-data (ement-room--ewoc-last-matching ement-ewoc
(lambda (data) (ement-event-p data))))))
(set-buffer-modified-p nil)
(if-let ((rooms-window (cl-find-if (lambda (window)
(member (buffer-name (window-buffer window))
'("*Ement Taxy*" "*Ement Rooms*")))
(window-list))))
;; Rooms buffer already displayed: select its window and move to next unread room.
(progn
(select-window rooms-window)
(funcall (pcase-exhaustive major-mode
('ement-tabulated-room-list-mode #'ement-tabulated-room-list-next-unread)
('ement-room-list-mode #'ement-room-list-next-unread))))
;; Rooms buffer not displayed: bury this room buffer, which should usually
;; result in another room buffer or the rooms list buffer being displayed.
(bury-buffer))
(when (member major-mode '(ement-tabulated-room-list-mode ement-room-list-mode))
;; Back in the room-list buffer: revert it.
(revert-buffer)))
;; Not at the bottom of the buffer: scroll.
(condition-case _err
(scroll-up-command)
(end-of-buffer (set-window-point nil (point-max))))))
(cl-defun ement-room-join (id-or-alias session &key then)
"Join room by ID-OR-ALIAS on SESSION.
THEN may be a function to call after joining the room (and when
`ement-room-join-view-buffer' is non-nil, after viewing the room
buffer). It receives two arguments, the room and the session."
(interactive (list (read-string "Join room (ID or alias): ")
(or ement-session
(ement-complete-session))))
(cl-assert id-or-alias) (cl-assert session)
(unless (string-match-p
;; According to tulir in #matrix-dev:matrix.org, ": is not
;; allowed in the localpart, all other valid unicode is
;; allowed. (user ids and room ids are the same over
;; federation). it's mostly a lack of validation in
;; synapse (arbitrary unicode isn't intentionally allowed,
;; but it's not disallowed either)". See
;; <https://matrix.to/#/!jxlRxnrZCsjpjDubDX:matrix.org/$Cnb53UQdYnGFizM49Aje_Xs0BxVdt-be7Dnm7_k-0ho>.
(rx bos (or "#" "!") (1+ (not (any ":")))
":" (1+ (or alnum (any "-."))))
id-or-alias)
(user-error "Invalid room ID or alias (use, e.g. \"#ROOM-ALIAS:SERVER\")"))
(let ((endpoint (format "join/%s" (url-hexify-string id-or-alias))))
(ement-api session endpoint :method 'post :data ""
:then (lambda (data)
;; NOTE: This generates a symbol and sets its function value to a lambda
;; which removes the symbol from the hook, removing itself from the hook.
;; TODO: When requiring Emacs 27, use `letrec'.
(pcase-let* (((map ('room_id room-id)) data)
(then-fns (delq nil
(list (when ement-room-join-view-buffer
(lambda (room session)
(ement-view-room room session)))
then)))
(then-fn-symbol (gensym (format "ement-join-%s" id-or-alias)))
(then-fn (lambda (session)
(when-let ((room (cl-loop for room in (ement-session-rooms session)
when (equal room-id (ement-room-id room))
return room)))
;; In case the join event is not in this next sync
;; response, make sure the room is found before removing
;; the function and joining the room.
(remove-hook 'ement-sync-callback-hook then-fn-symbol)
;; FIXME: Probably need to unintern the symbol.
(dolist (fn then-fns)
(funcall fn room session))))))
(setf (symbol-function then-fn-symbol) then-fn)
(add-hook 'ement-sync-callback-hook then-fn-symbol)
(message "Joined room: %s" room-id)))
:else (lambda (plz-error)
(pcase-let* (((cl-struct plz-error response) plz-error)
((cl-struct plz-response status body) response)
((map error) (json-read-from-string body)))
(pcase status
((or 403 429) (error "Unable to join room %s: %s" id-or-alias error))
(_ (error "Unable to join room %s: %s %S" id-or-alias status plz-error))))))))
(defalias 'ement-join-room #'ement-room-join)
(defun ement-room-goto-prev ()
"Go to the previous message in buffer."
(interactive)
(if (>= (point) (- (point-max) 2))
;; Point is actually on the last event, but it doesn't appear to be: move point to
;; the beginning of that event.
(ewoc-goto-node ement-ewoc (ewoc-locate ement-ewoc))
;; Go to previous event.
(ement-room-goto-next :next-fn #'ewoc-prev)))
(cl-defun ement-room-goto-next (&key (next-fn #'ewoc-next))
"Go to the next message in buffer.
NEXT-FN is passed to `ement-room--ewoc-next-matching', which
see."
(interactive)
(if-let (node (ement-room--ewoc-next-matching ement-ewoc
(ewoc-locate ement-ewoc) #'ement-event-p next-fn))
(ewoc-goto-node ement-ewoc node)
(user-error "End of events")))
(defun ement-room-scroll-down-command ()
"Scroll down, and load NUMBER earlier messages when at top."
(interactive)
(condition-case _err
(scroll-down nil)
(beginning-of-buffer
(call-interactively #'ement-room-retro))))
(defun ement-room-mwheel-scroll (event)
"Scroll according to EVENT, loading earlier messages when at top."
(interactive "e")
(with-selected-window (posn-window (event-start event))
(let ((start (window-start)))
(mwheel-scroll event)
(when (= start (window-start))
(call-interactively #'ement-room-retro)))))
;; TODO: Unify these retro-loading functions.
(cl-defun ement-room-retro
(room session number &key buffer
(then (apply-partially #'ement-room-retro-callback room session)))
;; FIXME: Naming things is hard.
"Retrieve NUMBER older messages in ROOM on SESSION."
(interactive (list ement-room ement-session
(cl-typecase current-prefix-arg
(null ement-room-retro-messages-number)
(list (read-number "Number of messages: "))
(number current-prefix-arg))
:buffer (current-buffer)))
(unless ement-room-retro-loading
(pcase-let* (((cl-struct ement-room id prev-batch) room)
(endpoint (format "rooms/%s/messages" (url-hexify-string id))))
;; We use a timeout of 30, because sometimes the server can take a while to
;; respond, especially if loading, e.g. hundreds or thousands of events.
(ement-api session endpoint :timeout 30
:params (list (list "from" prev-batch)
(list "dir" "b")
(list "limit" (number-to-string number))
(list "filter" (json-encode ement-room-messages-filter)))
:then then
:else (lambda (plz-error)
(when buffer
(with-current-buffer buffer
(setf ement-room-retro-loading nil)))
(signal 'ement-api-error (list (format "Loading %s earlier messages failed" number)
plz-error))))
(message "Loading %s earlier messages..." number)
(setf ement-room-retro-loading t))))
(cl-defun ement-room-retro-to (room session event-id &key then (batch-size 100) (limit 1000))
"Retrieve messages in ROOM on SESSION back to EVENT-ID.
When event is found, call function THEN. Search in batches of
BATCH-SIZE events up to a total of LIMIT."
(declare (indent defun))
(cl-assert
;; Ensure the event hasn't already been retrieved.
(not (gethash event-id (ement-session-events session))))
(let* ((total-retrieved 0)
;; TODO: Use letrec someday.
(callback-symbol (gensym "ement-room-retro-to-callback-"))
(callback (lambda (data)
(ement-room-retro-callback room session data)
(if (gethash event-id (ement-session-events session))
(progn
(message "Found event %S" event-id)
;; FIXME: Probably need to unintern the symbol.
(when then
(funcall then)))
;; FIXME: What if it hits the beginning of the timeline?
(if (>= (cl-incf total-retrieved batch-size) limit)
(message "%s older events retrieved without finding event %S"
limit event-id)
(message "Looking back for event %S (%s/%s events retrieved)"
event-id total-retrieved limit)
(ement-room-retro room session batch-size
:buffer (alist-get 'buffer (ement-room-local room))
:then callback-symbol))))))
(fset callback-symbol callback)
(ement-room-retro room session batch-size
:buffer (alist-get 'buffer (ement-room-local room))
:then callback-symbol)))
(cl-defun ement-room-retro-to-token (room session from to
&key (batch-size 100) (limit 1000))
"Retrieve messages in ROOM on SESSION back from FROM to TO.
Retrieve batches of BATCH-SIZE up to total LIMIT. FROM and TO
are sync batch tokens. Used for, e.g. filling gaps in
\"limited\" sync responses."
;; NOTE: We don't set `ement-room-retro-loading' since the room may
;; not have a buffer. This could theoretically allow a user to
;; overlap manual scrollback-induced loading of old messages with
;; this gap-filling loading, but that shouldn't matter, and probably
;; would be very rare, anyway.
(pcase-let* (((cl-struct ement-room id) room)
(endpoint (format "rooms/%s/messages" (url-hexify-string id)))
(then
(lambda (data)
(ement-room-retro-callback room session data
:set-prev-batch nil)
(pcase-let* (((map end chunk) data))
;; HACK: Comparing the END and TO tokens ought to
;; work for determining whether we are done
;; filling, but it isn't (maybe the server isn't
;; returning the TO token as END when there are no
;; more events), so instead we'll check the length
;; of the chunk.
(unless (< (length chunk) batch-size)
;; More pages remain to be loaded.
(let ((remaining-limit (- limit batch-size)))
(if (not (> remaining-limit 0))
;; FIXME: This leaves a gap if it's larger than 1,000 events.
;; Probably, the limit should be configurable, but it would be good
;; to find some way to remember the gap and fill it if the user
;; scrolls to it later (although that might be very awkward to do).
(display-warning 'ement-room-retro-to-token
(format "Loaded events in %S (%S) without filling gap; not filling further"
(ement-room-display-name room)
(or (ement-room-canonical-alias room)
(ement-room-id room))))
;; FIXME: Remove this message after further testing.
(message "Ement: Continuing to fill gap in %S (%S) (remaining limit: %s)"
(ement-room-display-name room)
(or (ement-room-canonical-alias room)
(ement-room-id room))
remaining-limit)
(ement-room-retro-to-token
room session end to :limit remaining-limit))))))))
;; FIXME: Remove this message after further testing.
(message "Ement: Filling gap in %S (%S)"
(ement-room-display-name room)
(or (ement-room-canonical-alias room)
(ement-room-id room)))
(ement-api session endpoint :timeout 30
:params (list (list "from" from)
(list "to" to)
(list "dir" "b")
(list "limit" (number-to-string batch-size))
(list "filter" (json-encode ement-room-messages-filter)))
:then then
:else (lambda (plz-error)
(signal 'ement-api-error
(list (format "Filling gap in %S (%S) failed"
(ement-room-display-name room)
(or (ement-room-canonical-alias room)
(ement-room-id room)))
plz-error))))))
;; NOTE: `declare-function' doesn't recognize cl-defun forms, so this declaration doesn't work.
(declare-function ement--sync "ement.el" t t)
(defun ement-room-sync (session &optional force)
"Sync SESSION (interactively, current buffer's).
If FORCE (interactively, with prefix), cancel any outstanding
sync requests. Also, update any room list buffers."
(interactive (list ement-session current-prefix-arg))
(ement--sync session :force force)
(cl-loop for buffer in (buffer-list)
when (member (buffer-local-value 'major-mode buffer)
'(ement-room-list-mode ement-tabulated-room-list-mode))
do (with-current-buffer buffer
(revert-buffer))))
(defun ement-room-view-event (event)
"Pop up buffer showing details of EVENT (interactively, the one at point).
EVENT should be an `ement-event' or `ement-room-membership-events' struct."
(interactive (list (ewoc-data (ewoc-locate ement-ewoc))))
(require 'pp)
(cl-labels ((event-alist
(event) (ement-alist :id (ement-event-id event)
:sender (ement-user-id (ement-event-sender event))
:content (ement-event-content event)
:origin-server-ts (ement-event-origin-server-ts event)
:type (ement-event-type event)
:state-key (ement-event-state-key event)
:unsigned (ement-event-unsigned event)
:receipts (ement-event-receipts event)
:local (ement-event-local event))))
(let* ((buffer-name (format "*Ement event: %s*"
(cl-typecase event
(ement-room-membership-events "[multiple events]")
(ement-event (ement-event-id event)))))
(event (cl-typecase event
(ement-room-membership-events
(mapcar #'event-alist (ement-room-membership-events-events event)))
(ement-event (event-alist event))))
(inhibit-read-only t))
(with-current-buffer (get-buffer-create buffer-name)
(erase-buffer)
(pp event (current-buffer))
(view-mode)
(pop-to-buffer (current-buffer))))))
(cl-defun ement-room-send-message (room session &key body formatted-body replying-to-event)
"Send message to ROOM on SESSION with BODY and FORMATTED-BODY.
Interactively, with prefix, prompt for room and session,
otherwise use current room.
REPLYING-TO-EVENT may be an event the message is in reply to; the
message will reference it appropriately.
If `ement-room-send-message-filter' is non-nil, the message's
content alist is passed through it before sending. This may be
used to, e.g. process the BODY into another format and add it to
the content (e.g. see `ement-room-send-org-filter')."
(interactive
(ement-with-room-and-session
(let* ((prompt (format "Send message (%s): " (ement-room-display-name ement-room)))
(body (ement-room-with-typing
(ement-room-read-string prompt nil nil nil
'inherit-input-method))))
(list ement-room ement-session :body body))))
(ement-send-message room session :body body :formatted-body formatted-body
:replying-to-event replying-to-event :filter ement-room-send-message-filter
:then #'ement-room-send-event-callback)
;; NOTE: This assumes that the selected window is the buffer's window. For now
;; this is almost surely the case, but in the future, we might let the function
;; send messages to other rooms more easily, so this assumption might not hold.
(when-let* ((buffer (alist-get 'buffer (ement-room-local room)))
(window (get-buffer-window buffer)))
(with-selected-window window
(when (>= (window-point) (ewoc-location (ewoc-nth ement-ewoc -1)))
;; Point is on last event: advance it to eob so that when the event is received
;; back, the window will scroll. (This might not always be desirable, because
;; the user might have point on that event for a reason, but I think in most
;; cases, it will be what's expected and most helpful.)
(setf (window-point) (point-max))))))
(cl-defun ement-room-send-emote (room session &key body)
"Send emote to ROOM on SESSION with BODY.
Interactively, with prefix, prompt for room and session,
otherwise use current room.
If `ement-room-send-message-filter' is non-nil, the message's
content alist is passed through it before sending. This may be
used to, e.g. process the BODY into another format and add it to
the content (e.g. see `ement-room-send-org-filter')."
(interactive
(ement-with-room-and-session
(let* ((prompt (format "Send emote (%s): " (ement-room-display-name ement-room)))
(body (ement-room-with-typing
(ement-room-read-string prompt nil nil nil
'inherit-input-method))))
(list ement-room ement-session :body body))))
(cl-assert (not (string-empty-p body)))
(pcase-let* (((cl-struct ement-room (id room-id) (local (map buffer))) room)
(window (when buffer (get-buffer-window buffer)))
(endpoint (format "rooms/%s/send/m.room.message/%s" (url-hexify-string room-id)
(ement--update-transaction-id session)))
(content (ement-aprog1
(ement-alist "msgtype" "m.emote"
"body" body))))
(when ement-room-send-message-filter
(setf content (funcall ement-room-send-message-filter content room)))
(ement-api session endpoint :method 'put :data (json-encode content)
:then (apply-partially #'ement-room-send-event-callback :room room :session session
:content content :data)) ;; Data is added when calling back.
;; NOTE: This assumes that the selected window is the buffer's window. For now
;; this is almost surely the case, but in the future, we might let the function
;; send messages to other rooms more easily, so this assumption might not hold.
(when window
(with-selected-window window
(when (>= (window-point) (ewoc-location (ewoc-nth ement-ewoc -1)))
;; Point is on last event: advance it to eob so that when the event is received
;; back, the window will scroll. (This might not always be desirable, because
;; the user might have point on that event for a reason, but I think in most
;; cases, it will be what's expected and most helpful.)
(setf (window-point) (point-max)))))))
(cl-defun ement-room-send-event-callback (&key data room session content)
"Callback for event-sending functions.
DATA is the parsed JSON object. If DATA's event ID is already
present in SESSION's events table, show an appropriate warning
mentioning the ROOM and CONTENT."
(pcase-let* (((map ('event_id event-id)) data))
(when (and ement-room-warn-for-already-seen-messages
(gethash event-id (ement-session-events session)))
(let ((message (format "Event ID %S already seen in session %S. This may indicate a reused transaction ID, which could mean that the event was not sent to the room (%S). You may need to disconnect, delete the `ement-sessions-file', and connect again to start a new session. Alternatively, this can happen if the event's sent-confirmation is received after the event itself is received in the next sync response, in which case no action is needed."
event-id (ement-user-id (ement-session-user session))
(ement-room-display-name room))))
(when content
(setf message (concat message (format " Event content: %S" content))))
(display-warning 'ement-room-send-event-callback message)))
(when (eq 'send ement-room-mark-rooms-read)
;; Move read markers.
(when-let ((buffer (alist-get 'buffer (ement-room-local room))))
(with-current-buffer buffer
;; NOTE: The new event may not exist in the buffer yet, so
;; we just have to use the last one.
;; FIXME: When we add local echo, this can be fixed.
(save-excursion
(goto-char (ewoc-location
(ement-room--ewoc-last-matching ement-ewoc #'ement-event-p)))
(call-interactively #'ement-room-mark-read)))))))
(defun ement-room-edit-message (event room session body)
"Edit EVENT in ROOM on SESSION to have new BODY.
The message must be one sent by the local user."
(interactive (ement-room-with-highlighted-event-at (point)
(cl-assert ement-session) (cl-assert ement-room)
(pcase-let* ((event (ewoc-data (ewoc-locate ement-ewoc)))
((cl-struct ement-session user) ement-session)
((cl-struct ement-event sender
(content (map body ('m.relates_to relates-to))))
event))
(unless (equal (ement-user-id sender) (ement-user-id user))
(user-error "You may only edit your own messages"))
(when relates-to
;; FIXME: This isn't quite right. When we show edits by replacing
;; the original event, this will need to be changed.
(user-error "Only original messages may be edited, not the edit events themselves"))
;; Remove any leading asterisk from the plain-text body.
(setf body (replace-regexp-in-string (rx bos "*" (1+ space)) "" body t t))
(ement-room-with-typing
(let* ((prompt (format "Edit message (%s): "
(ement-room-display-name ement-room)))
(body (ement-room-read-string prompt body nil nil
'inherit-input-method)))
(when (string-empty-p body)
(user-error "To delete a message, use command `ement-room-delete-message'"))
(when (yes-or-no-p (format "Edit message to: %S? " body))
(list event ement-room ement-session body)))))))
(let* ((endpoint (format "rooms/%s/send/%s/%s" (url-hexify-string (ement-room-id room))
"m.room.message" (ement--update-transaction-id session)))
(new-content (ement-alist "body" body
"msgtype" "m.text"))
(_ (when ement-room-send-message-filter
(setf new-content (funcall ement-room-send-message-filter new-content room))))
(content (ement-alist "msgtype" "m.text"
"body" body
"m.new_content" new-content
"m.relates_to" (ement-alist "rel_type" "m.replace"
"event_id" (ement-event-id event)))))
;; Prepend the asterisk after the filter may have modified the content. Note that the
;; "m.new_content" body does not get the leading asterisk, only the "content" body,
;; which is intended as a fallback.
(setf body (concat "* " body))
(ement-api session endpoint :method 'put :data (json-encode content)
:then (apply-partially #'ement-room-send-event-callback :room room :session session
:content content :data))))
(defun ement-room-delete-message (event room session &optional reason)
"Delete EVENT in ROOM on SESSION, optionally with REASON."
(interactive (ement-room-with-highlighted-event-at (point)
(if (yes-or-no-p "Delete this event? ")
(list (ewoc-data (ewoc-locate ement-ewoc))
ement-room ement-session (read-string "Reason (optional): " nil nil nil 'inherit-input-method))
;; HACK: This isn't really an error, but is there a cleaner way to cancel?
(user-error "Message not deleted"))))
(ement-redact event room session reason))
(defun ement-room-write-reply ()
"Send a reply to event at point."
(interactive)
(cl-assert ement-ewoc) (cl-assert ement-room) (cl-assert ement-session)
(cl-assert (ement-event-p (ewoc-data (ewoc-locate ement-ewoc))))
(ement-room-with-highlighted-event-at (point)
(pcase-let* ((event (ewoc-data (ewoc-locate ement-ewoc)))
(room ement-room)
(session ement-session)
(prompt (format "Send reply (%s): " (ement-room-display-name room)))
(ement-room-read-string-setup-hook
(lambda ()
(setq-local ement-room-replying-to-event event)))
(body (ement-room-with-typing
(ement-room-read-string prompt nil nil nil 'inherit-input-method))))
(ement-room-send-message room session :body body :replying-to-event event))))
(defun ement-room-send-reaction (key position)
"Send reaction of KEY to event at POSITION.
Interactively, send reaction to event at point. KEY should be a
reaction string, e.g. \"👍\"."
(interactive
(list (char-to-string (read-char-by-name "Reaction (prepend \"*\" for substring search): "))
(point)))
;; SPEC: MSC2677 <https://github.com/matrix-org/matrix-doc/pull/2677>
;; HACK: We could simplify this by storing the key in a text property...
(ement-room-with-highlighted-event-at position
(pcase-let* ((event (or (ewoc-data (ewoc-locate ement-ewoc position))
(user-error "No event at point")))
;; NOTE: Sadly, `face-at-point' doesn't work here because, e.g. if
;; hl-line-mode is enabled, it only returns the hl-line face.
((cl-struct ement-event (id event-id)) event)
((cl-struct ement-room (id room-id)) ement-room)
(endpoint (format "rooms/%s/send/m.reaction/%s" (url-hexify-string room-id)
(ement--update-transaction-id ement-session)))
(content (ement-alist "m.relates_to"
(ement-alist "rel_type" "m.annotation"
"event_id" event-id
"key" key))))
(ement-api ement-session endpoint :method 'put :data (json-encode content)
:then (apply-partially #'ement-room-send-event-callback
:room ement-room :session ement-session :content content
:data)))))
(defun ement-room-toggle-reaction (key event room session)
"Toggle reaction of KEY to EVENT in ROOM on SESSION."
(interactive
(cl-labels
((face-at-point-p
(face) (let ((face-at-point (get-text-property (point) 'face)))
(or (eq face face-at-point)
(and (listp face-at-point)
(member face face-at-point)))))
(buffer-substring-while
(beg pred &key (forward-fn #'forward-char))
"Return substring of current buffer from BEG while PRED is true."
(save-excursion
(goto-char beg)
(cl-loop while (funcall pred)
do (funcall forward-fn)
finally return (buffer-substring-no-properties beg (point)))))
(key-at
(pos) (cond ((face-at-point-p 'ement-room-reactions-key)
(buffer-substring-while
pos (lambda () (face-at-point-p 'ement-room-reactions-key))))
((face-at-point-p 'ement-room-reactions)
;; Point is in a reaction button but after the key.
(buffer-substring-while
(button-start (button-at pos))
(lambda () (face-at-point-p 'ement-room-reactions-key)))))))
(list (or (key-at (point))
(char-to-string (read-char-by-name "Reaction (prepend \"*\" for substring search): ")))
(ewoc-data (ewoc-locate ement-ewoc))
ement-room ement-session)))
(pcase-let* (((cl-struct ement-event (local (map reactions))) event)
((cl-struct ement-session user) session)
((cl-struct ement-user (id user-id)) user))
(if-let (reaction-event (cl-find-if (lambda (event)
(and (equal user-id (ement-user-id (ement-event-sender event)))
(equal key (map-nested-elt (ement-event-content event) '(m.relates_to key)))))
reactions))
;; Already sent this reaction: redact it.
(ement-redact reaction-event room session)
;; Send reaction.
(ement-room-send-reaction key (point)))))
(defun ement-room-reaction-button-action (button)
"Push reaction BUTTON at point."
;; TODO: Toggle reactions off with redactions (not in spec yet, but Element does it).
(save-excursion
(goto-char (button-start button))
(call-interactively #'ement-room-toggle-reaction)))
(defun ement-room-toggle-space (room space session)
;; Naming things is hard, but this seems the best balance between concision, ambiguity,
;; and consistency. The docstring is always there. (Or there's the sci-fi angle:
;; "spacing" a room...)
"Toggle ROOM's membership in SPACE on SESSION."
(interactive
(ement-with-room-and-session
:prompt-form (ement-complete-room :session ement-session
:predicate (lambda (room) (not (ement--room-space-p room))) )
(pcase-let* ((prompt (format "Toggle room %S's membership in space: "
(ement--format-room ement-room)))
;; TODO: Use different face for spaces the room is already in.
(`(,space ,_session) (ement-complete-room :session ement-session :prompt prompt :suggest nil
:predicate #'ement--room-space-p)))
(list ement-room space ement-session))))
(pcase-let* (((cl-struct ement-room (id child-id)) room)
(routing-server (progn
(string-match (rx (1+ (not (any ":"))) ":" (group (1+ anything))) child-id)
(match-string 1 child-id)))
(action (if (ement--room-in-space-p room space)
'remove 'add))
(data (pcase action
('add (ement-alist "via" (vector
;; FIXME: Finish and use the routing function.
;; (ement--room-routing room)
routing-server)))
('remove (make-hash-table)))))
(ement-put-state space "m.space.child" child-id data session
:then (lambda (response-data)
;; It appears that the server doesn't send the new event in the next sync (at
;; least, not to the client that put the state), so we must simulate receiving it.
(pcase-let* (((map event_id) response-data)
((cl-struct ement-session user) session)
((cl-struct ement-room (id child-id)) room)
(fake-event (make-ement-event :id event_id :type "m.space.child"
:sender user :state-key child-id
:content (json-read-from-string (json-encode data)))))
(push fake-event (ement-room-timeline space))
(run-hook-with-args 'ement-event-hook fake-event space session))
(ement-message "Room %S %s space %S"
(ement--format-room room)
(pcase action
('add "added to")
('remove "removed from"))
(ement--format-room space))))))
;;;; Functions
(defun ement-room-view (room session)
"Switch to a buffer showing ROOM on SESSION.
Uses action `ement-view-room-display-buffer-action', which see."
(interactive (ement-complete-room :session (ement-complete-session) :suggest nil
:predicate (lambda (room)
(not (ement--room-space-p room)))))
(pcase-let* (((cl-struct ement-room (local (map buffer))) room))
(unless (buffer-live-p buffer)
(setf buffer (ement-room--buffer session room (ement-room--buffer-name room))
(alist-get 'buffer (ement-room-local room)) buffer))
;; FIXME: This doesn't seem to work as desired, e.g. when
;; `ement-view-room-display-buffer-action' is set to `display-buffer-no-window'; I
;; guess because `pop-to-buffer' selects a window.
(pop-to-buffer buffer ement-view-room-display-buffer-action)
(run-hook-with-args 'ement-room-view-hook room session)))
(defalias 'ement-view-room #'ement-room-view)
(defun ement-room-view-hook-room-list-auto-update (_room session)
"Call `ement-room-list-auto-update' with SESSION.
To be used in `ement-room-view-hook', which see."
;; This function is necessary because the hook is called with the room argument, which
;; `ement-room-list-auto-update' doesn't need.
(declare (function ement-room-list-auto-update "ement-room-list"))
(ement-room-list-auto-update session))
(defun ement-room--buffer-name (room)
"Return name for ROOM's buffer."
(concat ement-room-buffer-name-prefix
(or (ement-room-display-name room)
(setf (ement-room-display-name room)
(ement--room-display-name room)))
ement-room-buffer-name-suffix))
(defun ement-room-goto-event (event)
"Go to EVENT in current buffer."
(if-let ((node (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(and (ement-event-p data)
(equal (ement-event-id event) (ement-event-id data)))))))
(goto-char (ewoc-location node))
(error "Event not found in buffer: %S" (ement-event-id event))))
(cl-defun ement-room-retro-callback (room session data
&key (set-prev-batch t))
"Push new DATA to ROOM on SESSION and add events to room buffer.
If SET-PREV-BATCH is nil, don't set ROOM's prev-batch slot to the
\"prev_batch\" token in response DATA (this should be set,
e.g. when filling timeline gaps as opposed to retrieving messages
before the earliest-seen message)."
(declare (function ement--make-event "ement.el")
(function ement--put-event "ement.el"))
(pcase-let* (((cl-struct ement-room local) room)
((map _start end chunk state) data)
((map buffer) local)
(num-events (length chunk))
;; We do 3 things for chunk events, so we count them 3 times when
;; reporting progress. (We also may receive some state events for
;; these chunk events, but we don't bother to include them in the
;; count, and we don't report progress for them, because they are
;; likely very few compared to the number of timeline events, which is
;; what the user is interested in (e.g. when loading 1000 earlier
;; messages in #emacs:matrix.org, only 31 state events were received).
(progress-max-value (* 3 num-events)))
;; NOTE: Put the newly retrieved events at the end of the slots, because they should be
;; older events. But reverse them first, because we're using "dir=b", which the
;; spec says causes the events to be returned in reverse-chronological order, and we
;; want to process them oldest-first (important because a membership event having a
;; user's displayname should be older than a message event sent by the user).
;; NOTE: The events in `chunk' and `state' are vectors, so we
;; convert them to a list before appending.
(ement-debug num-events progress-max-value)
(setf chunk (nreverse chunk)
state (nreverse state))
;; FIXME: Like `ement--push-joined-room-events', this should probably run the `ement-event-hook' on the newly seen events.
;; Append state events.
(cl-loop for event across-ref state
do (setf event (ement--make-event event))
finally do (setf (ement-room-state room)
(append (ement-room-state room) (append state nil))))
(ement-with-progress-reporter (:reporter ("Ement: Processing earlier events..." 0 progress-max-value))
;; Append timeline events (in the "chunk").
(cl-loop for event across-ref chunk
do (setf event (ement--make-event event))
;; HACK: Put events on events table. See FIXME above about using the event hook.
(ement--put-event event nil session)
(ement-progress-update)
finally do (setf (ement-room-timeline room)
(append (ement-room-timeline room) (append chunk nil))))
(when buffer
;; Insert events into the room's buffer.
(with-current-buffer buffer
(save-window-excursion
;; NOTE: See note in `ement--update-room-buffers'.
(when-let ((buffer-window (get-buffer-window buffer)))
(select-window buffer-window))
;; FIXME: Use retro-loading in event handlers, or in --handle-events, anyway.
(ement-room--process-events chunk)
(when set-prev-batch
;; This feels a little hacky, but maybe not too bad.
(setf (ement-room-prev-batch room) end))
(setf ement-room-retro-loading nil)))))
(message "Ement: Loaded %s earlier events." num-events)))
(defun ement-room--insert-events (events &optional retro)
"Insert EVENTS into current buffer.
Calls `ement-room--insert-event' for each event and inserts
timestamp headers into appropriate places while maintaining
point's position. If RETRO is non-nil, assume EVENTS are earlier
than any existing events, and only insert timestamp headers up to
the previously oldest event."
(let (buffer-window point-node orig-first-node point-max-p)
(when (get-buffer-window (current-buffer))
;; HACK: See below.
(setf buffer-window (get-buffer-window (current-buffer))
point-max-p (= (point) (point-max))))
(when (and buffer-window retro)
(setf point-node (ewoc-locate ement-ewoc (window-start buffer-window))
orig-first-node (ewoc-nth ement-ewoc 0)))
(save-window-excursion
;; NOTE: When inserting some events, seemingly only replies, if a different buffer's
;; window is selected, and this buffer's window-point is at the bottom, the formatted
;; events may be inserted into the wrong place in the buffer, even though they are
;; inserted into the EWOC at the right place. We work around this by selecting the
;; buffer's window while inserting events, if it has one. (I don't know if this is a bug
;; in EWOC or in this file somewhere. But this has been particularly nasty to debug.)
(when buffer-window
(select-window buffer-window))
(cl-loop for event being the elements of events
do (ement-room--process-event event)
do (ement-progress-update)))
;; Since events can be received in any order, we have to check the whole buffer
;; for where to insert new timestamp headers. (Avoiding that would require
;; getting a list of newly inserted nodes and checking each one instead of every
;; node in the buffer. Doing that now would probably be premature optimization,
;; though it will likely be necessary if users keep buffers open for busy rooms
;; for a long time, as the time to do this in each buffer will increase with the
;; number of events. At least we only do it once per batch of events.)
(ement-room--insert-ts-headers nil (when retro orig-first-node))
(when ement-room-sender-in-headers
(ement-room--insert-sender-headers ement-ewoc))
(when buffer-window
(cond (retro (with-selected-window buffer-window
(set-window-start buffer-window (ewoc-location point-node))
;; TODO: Experiment with this.
(forward-line -1)))
(point-max-p (set-window-point buffer-window (point-max)))))))
(cl-defun ement-room--send-typing (session room &key (typing t))
"Send a typing notification for ROOM on SESSION."
(pcase-let* (((cl-struct ement-session user) session)
((cl-struct ement-user (id user-id)) user)
((cl-struct ement-room (id room-id)) room)
(endpoint (format "rooms/%s/typing/%s"
(url-hexify-string room-id) (url-hexify-string user-id)))
(data (ement-alist "typing" typing "timeout" 20000)))
(ement-api session endpoint :method 'put :data (json-encode data)
;; We don't really care about the response, I think.
:then #'ignore)))
(define-derived-mode ement-room-mode fundamental-mode
`("Ement-Room"
(:eval (unless (map-elt ement-syncs ement-session)
(propertize ":Not-syncing"
'face 'font-lock-warning-face
'help-echo "Automatic syncing was interrupted; press \"g\" to resume"))))
"Major mode for Ement room buffers.
This mode initializes a buffer to be used for showing events in
an Ement room. It kills all local variables, removes overlays,
and erases the buffer."
(let ((inhibit-read-only t))
(erase-buffer))
(remove-overlays)
(setf buffer-read-only t
left-margin-width ement-room-left-margin-width
right-margin-width ement-room-right-margin-width
imenu-create-index-function #'ement-room--imenu-create-index-function
;; TODO: Use EWOC header/footer for, e.g. typing messages.
ement-ewoc (ewoc-create #'ement-room--pp-thing))
;; Set the URL handler. Note that `browse-url-handlers' was added in 28.1;
;; prior to that `browse-url-browser-function' served double-duty.
;; TODO: Remove compat code when requiring Emacs >=28.
(let ((handler (cons ement-room-matrix.to-url-regexp #'ement-room-browse-url)))
(if (boundp 'browse-url-handlers)
(setq-local browse-url-handlers (cons handler browse-url-handlers))
(setq-local browse-url-browser-function
(cons handler
(if (consp browse-url-browser-function)
browse-url-browser-function
(and browse-url-browser-function
(list (cons "." browse-url-browser-function))))))))
(setq-local completion-at-point-functions
'(ement-room--complete-members-at-point ement-room--complete-rooms-at-point))
(setq-local dnd-protocol-alist (append '(("^file:///" . ement-room-dnd-upload-file)
("^file:" . ement-room-dnd-upload-file))
dnd-protocol-alist)))
(add-hook 'ement-room-mode-hook 'visual-line-mode)
(defun ement-room-read-string (prompt &optional initial-input history default-value inherit-input-method)
"Call `read-from-minibuffer', binding variables and keys for Ement.
Arguments PROMPT, INITIAL-INPUT, HISTORY, DEFAULT-VALUE, and
INHERIT-INPUT-METHOD are as those expected by `read-string',
which see. Runs hook `ement-room-read-string-setup-hook', which
see."
(let ((room ement-room)
(session ement-session))
(minibuffer-with-setup-hook
(lambda ()
"Bind keys and variables locally (to be called in minibuffer)."
(setq-local ement-room room)
(setq-local ement-session session)
(setq-local completion-at-point-functions
'(ement-room--complete-members-at-point ement-room--complete-rooms-at-point))
(visual-line-mode 1)
(run-hooks 'ement-room-read-string-setup-hook))
(read-from-minibuffer prompt initial-input ement-room-minibuffer-map
nil history default-value inherit-input-method))))
(defun ement-room--buffer (session room name)
"Return buffer named NAME showing ROOM's events on SESSION.
If ROOM has no buffer, one is made and stored in the room's local
data slot."
(or (map-elt (ement-room-local room) 'buffer)
(let ((new-buffer (generate-new-buffer name)))
(with-current-buffer new-buffer
(ement-room-mode)
(setf header-line-format (when ement-room-header-line-format
'ement-room-header-line-format)
ement-session session
ement-room room
list-buffers-directory (or (ement-room-canonical-alias room)
(ement-room-id room))
;; Track buffer in room's slot.
(map-elt (ement-room-local room) 'buffer) (current-buffer))
(add-hook 'kill-buffer-hook
(lambda ()
(setf (map-elt (ement-room-local room) 'buffer) nil))
nil 'local)
(setq-local bookmark-make-record-function #'ement-room-bookmark-make-record)
;; Set initial header and footer. (Do this before processing events, which
;; might cause the header/footer to be changed (e.g. a tombstone event).
(let ((header (if (cl-loop for state in (list (ement-room-state ement-room)
(ement-room-invite-state ement-room))
thereis (cl-find "m.room.encryption" state
:test #'equal :key #'ement-event-type))
(propertize "This appears to be an encrypted room, which is not natively supported by Ement.el. (See information about using Pantalaimon in Ement.el documentation.)"
'face 'font-lock-warning-face)
""))
(footer (pcase (ement-room-status ement-room)
;; Set header and footer for an invited room.
('invite
(concat (propertize "You've been invited to this room. "
'face 'font-lock-warning-face)
(propertize "[Join this room]"
'button '(t)
'category 'default-button
'mouse-face 'highlight
'follow-link t
'action (lambda (_button)
;; Kill the room buffer so it can be recreated after joining
;; (which will cleanly update the room's name, footer, etc).
(let ((room ement-room)
(session ement-session))
(kill-buffer)
(message "Joining room... (buffer will be reopened after joining)")
(ement-room-join (ement-room-id room) session))))))
(_ ""))))
(ewoc-set-hf ement-ewoc header footer))
(setf
;; Clear new-events, because those only matter when a buffer is already open.
(alist-get 'new-events (ement-room-local room)) nil
;; Set the new buffer in the room's local alist so that it
;; can be used by event-inserting functions before this
;; function returns, e.g. `ement-room--add-member-face'.
(alist-get 'buffer (ement-room-local room)) new-buffer)
;; We don't use `ement-room--insert-events' to avoid extra
;; calls to `ement-room--insert-ts-headers'.
;; NOTE: We handle the events in chronological order (i.e. the reverse of the
;; stored order, which is latest-first), because some logic depends on this
;; (e.g. processing a message-edit event before the edited event would mean the
;; edited event would not yet be in the buffer).
(ement-room--process-events (reverse (ement-room-state room)))
(ement-room--process-events (reverse (ement-room-timeline room)))
(ement-room--insert-ts-headers)
(when ement-room-sender-in-headers
(ement-room--insert-sender-headers ement-ewoc))
(ement-room-move-read-markers room
:read-event (when-let ((event (alist-get "m.read" (ement-room-account-data room) nil nil #'equal)))
(map-nested-elt event '(content event_id)))
:fully-read-event (when-let ((event (alist-get "m.fully_read" (ement-room-account-data room) nil nil #'equal)))
(map-nested-elt event '(content event_id)))))
;; Return the buffer!
new-buffer)))
(defun ement-room--event-data (id)
"Return event struct for event ID in current buffer."
;; Search from bottom, most likely to be faster.
(cl-loop with node = (ewoc-nth ement-ewoc -1)
while node
for data = (ewoc-data node)
when (and (ement-event-p data)
(equal id (ement-event-id data)))
return data
do (setf node (ewoc-prev ement-ewoc node))))
(defun ement-room--escape-% (string)
"Return STRING with \"%\" escaped.
Needed to display things in the header line."
(replace-regexp-in-string (rx "%") "%%" string t t))
;;;;; Imenu
(defconst ement-room-timestamp-header-imenu-format "%Y-%m-%d (%A) %H:%M"
"Format string for timestamps in Imenu indexes.")
(defun ement-room--imenu-create-index-function ()
"Return Imenu index for the current buffer.
For use as `imenu-create-index-function'."
(let ((timestamp-nodes (ement-room--ewoc-collect-nodes
ement-ewoc (lambda (node)
(pcase (ewoc-data node)
(`(ts . ,_) t))))))
(cl-loop for node in timestamp-nodes
collect (pcase-let*
((`(ts ,timestamp) (ewoc-data node))
(formatted (format-time-string ement-room-timestamp-header-imenu-format timestamp)))
(cons formatted (ewoc-location node))))))
;;;;; Occur
(defvar-local ement-room-occur-pred nil
"Predicate used to refresh `ement-room-occur' buffers.")
(define-derived-mode ement-room-occur-mode ement-room-mode "Ement-Room-Occur")
(progn
(define-key ement-room-occur-mode-map [remap ement-room-send-message] #'ement-room-occur-find-event)
(define-key ement-room-occur-mode-map (kbd "g") #'revert-buffer)
(define-key ement-room-occur-mode-map (kbd "n") #'ement-room-occur-next)
(define-key ement-room-occur-mode-map (kbd "p") #'ement-room-occur-prev))
(cl-defun ement-room-occur (&key user-id regexp pred header)
"Show known events in current buffer matching args in a new buffer.
If REGEXP, show events whose sender or body content match it. Or
if USER-ID, show events from that user. Or if PRED, show events
matching it. HEADER is used if given, or set according to other
arguments."
(interactive (let* ((regexp (read-regexp "Regexp (leave empty to select user instead)"))
(user-id (when (string-empty-p regexp)
(ement-complete-user-id))))
(list :regexp regexp :user-id user-id)))
(let* ((session ement-session)
(room ement-room)
(occur-buffer (get-buffer-create (format "*Ement Room Occur: %s*" (ement-room-display-name room))))
(pred (cond (pred)
((not (string-empty-p regexp))
(lambda (data)
(and (ement-event-p data)
(or (string-match regexp (ement-user-id (ement-event-sender data)))
(when-let ((room-display-name
(gethash (ement-event-sender data) (ement-room-displaynames room))))
(string-match regexp room-display-name))
(when-let ((body (alist-get 'body (ement-event-content data))))
(string-match regexp body))))))
(user-id
(lambda (data)
(and (ement-event-p data)
(equal user-id (ement-user-id (ement-event-sender data))))))))
(header (cond (header)
((not (string-empty-p regexp))
(format "Events matching %S in %s" regexp (ement-room-display-name room)))
(user-id
(format "Events from %s in %s" user-id (ement-room-display-name room))))))
(with-current-buffer occur-buffer
(let ((inhibit-read-only t))
(erase-buffer))
(ement-room-occur-mode)
(setf header-line-format header
ement-session session
ement-room room)
(setq-local revert-buffer-function (lambda (&rest _)
(interactive)
(let ((event-at-point (ewoc-data (ewoc-locate ement-ewoc))))
(with-current-buffer (alist-get 'buffer (ement-room-local room))
(ement-room-occur :pred pred :header header)
(when-let ((node (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(eq event-at-point data)))))
(ewoc-goto-node ement-ewoc node))))))
(ement-room--process-events (reverse (ement-room-state room)))
(ement-room--process-events (reverse (ement-room-timeline room)))
(ewoc-filter ement-ewoc pred)
;; TODO: Insert date header before first event.
(ement-room--insert-ts-headers))
(pop-to-buffer occur-buffer)))
(defun ement-room-occur-find-event (event)
"Find EVENT in room's main buffer."
(interactive (list (ewoc-data (ewoc-locate ement-ewoc))))
(pcase-let* (((cl-struct ement-room (local (map buffer))) ement-room)
((cl-struct ement-event id) event))
(display-buffer buffer)
(with-selected-window (get-buffer-window buffer)
(ement-room-find-event id))))
(cl-defun ement-room-occur-next (&optional (n 1))
"Go to Nth next event."
(interactive)
(let ((command (if (> n 0)
#'ement-room-goto-next
#'ement-room-goto-prev)))
(cl-loop for i below (abs n)
do (call-interactively command))
(ement-room-occur-find-event (ewoc-data (ewoc-locate ement-ewoc)))))
(cl-defun ement-room-occur-prev (&optional (n 1))
"Go to Nth previous event."
(interactive)
(ement-room-occur-next (- n)))
;;;;; Events
;; Functions to handle types of events.
;; NOTE: At the moment, this only handles "m.typing" ephemeral events. Message
;; events are handled elsewhere. A better framework should be designed...
;; TODO: Define other handlers this way.
;; MAYBE: Should we intern these functions? That means every event
;; handled has to concat and intern. Should we use lambdas in an
;; alist or hash-table instead? For now let's use an alist.
(defvar ement-users)
(defvar ement-room-event-fns nil
"Alist mapping event types to functions which process events in room buffers.")
;; NOTE: While transitioning to the defevent-based handler system, we
;; define both a handle-events and handle-event function that do the
;; same thing.
;; TODO: Tidy this up.
;; NOTE: --handle-events and --handle-event need to be called in the room
;; buffer's window, when it has one. This is absolutely necessary,
;; otherwise the events may be inserted at the wrong place. (I'm not
;; sure if this is a bug in EWOC or in my code, but doing this fixes it.)
(defun ement-room--process-events (events)
"Process EVENTS in current buffer.
Calls `ement-progress-update' for each event. Calls
`ement-room--insert-ts-headers' when done. Uses handlers defined
in `ement-room-event-fns'. The current buffer should be a room's
buffer."
;; FIXME: Calling `ement-room--insert-ts-headers' is convenient, but it
;; may also be called in functions that call this function, which may
;; result in it being called multiple times for a single set of events.
(cl-loop for event being the elements of events ;; EVENTS may be a list or array.
for handler = (alist-get (ement-event-type event) ement-room-event-fns nil nil #'equal)
when handler
do (funcall handler event)
do (ement-progress-update))
(ement-room--insert-ts-headers))
(defun ement-room--process-event (event)
"Process EVENT in current buffer.
Uses handlers defined in `ement-room-event-fns'. The current
buffer should be a room's buffer."
(when-let ((handler (alist-get (ement-event-type event) ement-room-event-fns nil nil #'equal)))
;; We demote any errors that happen while processing events, because it's possible for
;; events to be malformed in unexpected ways, and that could cause an error, which
;; would stop processing of other events and prevent further syncing. See,
;; e.g. <https://github.com/alphapapa/ement.el/pull/61>.
(with-demoted-errors "Ement (ement-room--process-event): Error processing event: %S"
(funcall handler event))))
;;;;;; Event handlers
(defmacro ement-room-defevent (type &rest body)
"Define an event handling function for events of TYPE.
Around the BODY, the variable `event' is bound to the event being
processed. The function is called in the room's buffer. Adds
function to `ement-room-event-fns', which see."
(declare (debug (stringp def-body))
(indent defun))
`(setf (alist-get ,type ement-room-event-fns nil nil #'string=)
(lambda (event)
,(concat "`ement-room' handler function for " type " events.")
,@body)))
(ement-room-defevent "m.reaction"
(pcase-let* (((cl-struct ement-event content) event)
((map ('m.relates_to relates-to)) content)
((map ('event_id related-id) ('rel_type rel-type) _key) relates-to))
;; TODO: Handle other rel_types?
(pcase rel-type
("m.annotation"
;; Look for related event in timeline.
(if-let ((related-event (cl-loop with fake-event = (make-ement-event :id related-id)
for timeline-event in (ement-room-timeline ement-room)
when (ement--events-equal-p fake-event timeline-event)
return timeline-event)))
;; Found related event: add reaction to local slot and invalidate node.
(progn
;; Every time a room buffer is made, these reaction events are processed again, so we use pushnew to
;; avoid duplicates. (In the future, as event-processing is refactored, this may not be necessary.)
(cl-pushnew event (map-elt (ement-event-local related-event) 'reactions))
(when-let ((nodes (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(and (ement-event-p data)
(equal related-id (ement-event-id data)))))))
(ewoc-invalidate ement-ewoc nodes)))
;; No known related event: discard.
;; TODO: Is this the correct thing to do?
(ement-debug "No known related event for" event))))))
(ement-room-defevent "m.room.power_levels"
(ement-room--insert-event event))
(defun ement-room--format-power-levels-event (event room _session)
"Return power-levels EVENT in ROOM formatted as a string."
(pcase-let (((cl-struct ement-event sender
(content (map ('users new-users)))
(unsigned (map ('prev_content (map ('users old-users))))))
event))
(when old-users
(pcase-let* ((sender-id (ement-user-id sender))
(sender-displayname (ement--user-displayname-in room sender))
(`(,changed-user-id-symbol . ,new-level)
(cl-find-if (lambda (new-user)
(let ((old-user (cl-find (car new-user) old-users
:key #'car)))
(or (not old-user)
(not (equal (cdr new-user) (cdr old-user))))))
new-users))
(changed-user-id (symbol-name changed-user-id-symbol))
(changed-user (when changed-user-id-symbol
(gethash changed-user-id ement-users)))
(user-displayname (if changed-user
(ement--user-displayname-in room changed-user)
changed-user-id)))
(ement-room-wrap-prefix
(if (not changed-user)
(format "%s sent a power-level event"
(propertize sender-displayname
'help-echo sender-id))
(format "%s set %s's power level to %s"
(propertize sender-displayname
'help-echo sender-id)
(propertize user-displayname 'help-echo changed-user-id)
new-level))
'face 'ement-room-membership)))))
(ement-room-defevent "m.room.canonical_alias"
(ement-room--insert-event event))
(defun ement-room--format-canonical-alias-event (event room _session)
"Return canonical alias EVENT in ROOM formatted as a string."
(pcase-let (((cl-struct ement-event sender
;; TODO: Include alt_aliases, maybe.
;; TODO: Include old alias when it is being replaced.
(content (map alias)))
event))
(ement-room-wrap-prefix
(format "%s set the canonical alias to <%s>"
(propertize (ement--user-displayname-in room sender)
'help-echo (ement-user-id sender))
alias)
'face 'ement-room-membership)))
(ement-room-defevent "m.room.redaction"
;; We handle redaction events here rather than an `ement-defevent' handler. This way we
;; do less work for events in rooms that the user isn't looking at, at the cost of doing
;; a bit more work when a room's buffer is prepared.
(pcase-let* (((cl-struct ement-event (local (map ('redacts redacted-id)))) event)
((cl-struct ement-room timeline) ement-room)
(redacted-event (cl-find redacted-id timeline
:key #'ement-event-id :test #'equal)))
(when redacted-event
(pcase-let* (((cl-struct ement-event (content
(map ('m.relates_to
(map ('event_id related-id)
('rel_type rel-type))))))
redacted-event))
;; Record the redaction in the redacted event's local slot.
(cl-pushnew event (alist-get 'redacted-by (ement-event-local redacted-event)))
(pcase rel-type
("m.annotation"
;; Redacted annotation/reaction. NOTE: Since we link annotations in a -room
;; event handler (rather than in a non-room handler), we also unlink redacted
;; ones here.
(when-let (annotated-event (cl-find related-id timeline
:key #'ement-event-id :test #'equal))
;; Remove it from the related event's local slot.
(setf (map-elt (ement-event-local annotated-event) 'reactions)
(cl-remove redacted-id (map-elt (ement-event-local annotated-event) 'reactions)
:key #'ement-event-id :test #'equal))
;; Invalidate the related event's node.
(when-let (node (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(and (ement-event-p data)
(equal related-id (ement-event-id data))))))
(ewoc-invalidate ement-ewoc node)))))
;; Invalidate the redacted event's node.
(when-let (node (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(and (ement-event-p data)
(equal redacted-id (ement-event-id data))))))
(ewoc-invalidate ement-ewoc node))))))
(ement-room-defevent "m.typing"
(pcase-let* (((cl-struct ement-session user) ement-session)
((cl-struct ement-user (id local-user-id)) user)
((cl-struct ement-event content) event)
((map ('user_ids user-ids)) content)
(usernames) (footer))
(setf user-ids (delete local-user-id user-ids))
(if (zerop (length user-ids))
(setf footer "")
(setf usernames (cl-loop for id across user-ids
for user = (gethash id ement-users)
if user
collect (ement--user-displayname-in ement-room user)
else collect id)
footer (propertize (concat "Typing: " (string-join usernames ", "))
'face 'font-lock-comment-face)))
(with-silent-modifications
(ewoc-set-hf ement-ewoc "" footer))))
(ement-room-defevent "m.room.avatar"
(ement-room--insert-event event))
(ement-room-defevent "org.matrix.msc3015.m.room.name.override"
(ignore event)
(setf (ement-room-display-name ement-room) (ement--room-display-name ement-room))
(rename-buffer (ement-room--buffer-name ement-room)))
(ement-room-defevent "m.room.member"
(with-silent-modifications
(ement-room--insert-event event)))
(ement-room-defevent "m.room.message"
(pcase-let* (((cl-struct ement-event content unsigned) event)
((map ('m.relates_to (map ('rel_type rel-type) ('event_id replaces-event-id)))) content)
((map ('m.relations (map ('m.replace (map ('event_id replaced-by-id)))))) unsigned))
(if (and ement-room-replace-edited-messages
replaces-event-id (equal "m.replace" rel-type))
;; Event replaces existing event: find and replace it in buffer if possible, otherwise insert it.
(or (ement-room--replace-event event)
(progn
(ement-debug "Unable to replace event ID: inserting instead." replaces-event-id)
(ement-room--insert-event event)))
;; New event.
(if replaced-by-id
(ement-debug "Event replaced: not inserting." replaced-by-id)
;; Not replaced: insert it.
(ement-room--insert-event event)))))
(ement-room-defevent "m.room.tombstone"
(pcase-let* (((cl-struct ement-event content) event)
((map body ('replacement_room new-room-id)) content)
(session ement-session)
(button (ement--button-buttonize
(propertize new-room-id 'help-echo "Join replacement room")
(lambda (_)
(ement-room-join new-room-id session))))
(banner (format "This room has been replaced. Explanation:%S Replacement room: <%s>" body button)))
(add-face-text-property 0 (length banner) 'font-lock-warning-face t banner)
;; NOTE: We assume that no more typing events will be received,
;; which would replace the footer.
(ement-room--insert-event event)
(ewoc-set-hf ement-ewoc banner banner)))
;;;;; Read markers
;; Marking rooms as read and showing lines where marks are.
(ement-room-defevent "m.read"
(ement-room-move-read-markers ement-room
:read-event (ement-event-id event)))
(ement-room-defevent "m.fully_read"
(ement-room-move-read-markers ement-room
:fully-read-event (ement-event-id event)))
(defvar-local ement-room-read-receipt-marker nil
"EWOC node for the room's read-receipt marker.")
(defvar-local ement-room-fully-read-marker nil
"EWOC node for the room's fully-read marker.")
(defface ement-room-read-receipt-marker
'((t (:inherit show-paren-match)))
"Read marker line in rooms.")
(defface ement-room-fully-read-marker
'((t (:inherit isearch)))
"Fully read marker line in rooms.")
(defcustom ement-room-send-read-receipts t
"Whether to send read receipts.
Also controls whether the read-receipt marker in a room is moved
automatically."
:type 'boolean
:group 'ement-room)
(defun ement-room-read-receipt-idle-timer ()
"Update read receipts in visible Ement room buffers.
To be called from timer stored in
`ement-read-receipt-idle-timer'."
(when ement-room-send-read-receipts
(dolist (window (window-list))
(when (and (eq 'ement-room-mode (buffer-local-value 'major-mode (window-buffer window)))
(buffer-local-value 'ement-room (window-buffer window)))
(ement-room-update-read-receipt window)))))
(defun ement-room-update-read-receipt (window)
"Update read receipt for room displayed in WINDOW.
Also, mark room's buffer as unmodified."
(with-selected-window window
(let ((read-receipt-node (ement-room--ewoc-last-matching ement-ewoc
(lambda (node-data)
(eq 'ement-room-read-receipt-marker node-data))))
(window-end-node (or (ewoc-locate ement-ewoc (window-end nil t))
(ewoc-nth ement-ewoc -1))))
(when (or
;; The window's end has been scrolled to or past the position of the
;; receipt marker.
(and read-receipt-node
(>= (window-end nil t) (ewoc-location read-receipt-node)))
;; The read receipt is outside of retrieved events.
(not read-receipt-node))
(let* ((event-node (when window-end-node
;; It seems like `window-end-node' shouldn't ever be nil,
;; but just in case...
(cl-typecase (ewoc-data window-end-node)
(ement-event window-end-node)
(t (ement-room--ewoc-next-matching ement-ewoc window-end-node
#'ement-event-p #'ewoc-prev)))))
(node-after-event (ewoc-next ement-ewoc event-node))
(event))
(when event-node
(unless (or (when node-after-event
(<= (ewoc-location node-after-event) (window-end nil t)))
(>= (window-end) (point-max)))
;; The entire event is not visible: use the previous event. (NOTE: This
;; isn't quite perfect, because apparently `window-end' considers a position
;; visible if even one pixel of its line is visible. This will have to be
;; good enough for now.)
;; FIXME: Workaround that an entire line's height need not be displayed for it to be considered so.
(setf event-node (ement-room--ewoc-next-matching ement-ewoc event-node
#'ement-event-p #'ewoc-prev)))
(setf event (ewoc-data event-node))
;; Mark the buffer as not modified so that will not contribute to its being
;; considered unread. NOTE: This will mean that any room buffer displayed in
;; a window will have its buffer marked unmodified when this function is
;; called. This is probably for the best.
(set-buffer-modified-p nil)
(unless (alist-get event ement-room-read-receipt-request)
;; No existing request for this event: cancel any outstanding request and
;; send a new one.
(when-let ((request-process (car (map-values ement-room-read-receipt-request))))
(when (process-live-p request-process)
(interrupt-process request-process)))
(setf ement-room-read-receipt-request nil)
(setf (alist-get event ement-room-read-receipt-request)
(ement-room-mark-read ement-room ement-session
:read-event event)))))))))
(defun ement-room-goto-fully-read-marker ()
"Move to the fully-read marker in the current room."
(interactive)
(if-let ((fully-read-pos (when ement-room-fully-read-marker
(ewoc-location ement-room-fully-read-marker))))
(setf (point) fully-read-pos (window-start) fully-read-pos)
;; Unlike the fully-read marker, there doesn't seem to be a
;; simple way to get the user's read-receipt marker. So if
;; we haven't seen either marker in the retrieved events, we
;; go back to the fully-read marker.
(if-let* ((fully-read-event (alist-get "m.fully_read" (ement-room-account-data ement-room) nil nil #'equal))
(fully-read-event-id (map-nested-elt fully-read-event '(content event_id))))
;; Fully-read account-data event is known.
(if (gethash fully-read-event-id (ement-session-events ement-session))
;; The fully-read event (i.e. the message event that was read, not the
;; account-data event) is already retrieved, but the marker is not present in
;; the buffer (this shouldn't happen, but somehow, it can): Reset the marker,
;; which should work around the problem.
(ement-room-mark-read ement-room ement-session
:fully-read-event (gethash fully-read-event-id (ement-session-events ement-session)))
;; Fully-read event not retrieved: search for it in room history.
(let ((buffer (current-buffer)))
(message "Searching for first unread event...")
(ement-room-retro-to ement-room ement-session fully-read-event-id
:then (lambda ()
(with-current-buffer buffer
;; HACK: Should probably call this function elsewhere, in a hook or something.
(ement-room-move-read-markers ement-room)
(ement-room-goto-fully-read-marker))))))
(error "Room has no fully-read event"))))
(cl-defun ement-room-mark-read (room session &key read-event fully-read-event)
"Mark ROOM on SESSION as read on the server.
Set \"m.read\" to READ-EVENT and \"m.fully_read\" to
FULLY-READ-EVENT. Return the API request.
Interactively, mark both types as read up to event at point."
(declare (indent defun))
(interactive
(progn
(cl-assert (equal 'ement-room-mode major-mode) nil
"This command is to be used in `ement-room-mode' buffers")
(let* ((node (ewoc-locate ement-ewoc))
(event-at-point (cl-typecase (ewoc-data node)
(ement-event (ewoc-data node))
(t (when-let ((prev-event-node (ement-room--ewoc-next-matching ement-ewoc node
#'ement-event-p #'ewoc-prev)))
(ewoc-data prev-event-node)))))
(last-event (ewoc-data (ement-room--ewoc-last-matching ement-ewoc #'ement-event-p)))
(event-to-mark-read (if (eq event-at-point last-event)
;; The node is at the end of the buffer: use the last event in the timeline
;; instead of the last node in the EWOC, because the last event in the timeline
;; might not be the last event in the EWOC (e.g. a reaction to an earlier event).
(car (ement-room-timeline ement-room))
event-at-point)))
(list ement-room ement-session
:read-event event-to-mark-read
:fully-read-event event-to-mark-read))))
(cl-assert room) (cl-assert session) (cl-assert (or read-event fully-read-event))
(if (not fully-read-event)
;; Sending only a read receipt, which uses a different endpoint
;; than when setting the fully-read marker or both.
(ement-room-send-receipt room session read-event)
;; Setting the fully-read marker, and maybe the "m.read" one too.
(pcase-let* (((cl-struct ement-room (id room-id)) room)
(endpoint (format "rooms/%s/read_markers" (url-hexify-string room-id)))
(data (ement-alist "m.fully_read" (ement-event-id fully-read-event))))
(when read-event
(push (cons "m.read" (ement-event-id read-event)) data))
;; NOTE: See similar code in `ement-room-update-read-receipt'.
(let ((request-process (ement-api session endpoint :method 'post :data (json-encode data)
:then (lambda (_data)
(ement-room-move-read-markers room
:read-event read-event :fully-read-event fully-read-event))
:else (lambda (plz-error)
(pcase (plz-error-message plz-error)
("curl process interrupted"
;; Ignore this, because it happens when we
;; update a read marker before the previous
;; update request is completed.
nil)
(_ (signal 'ement-api-error
(list (format "Ement: (ement-room-mark-read) Unexpected API error: %s"
plz-error)
plz-error))))))))
(when-let ((room-buffer (alist-get 'buffer (ement-room-local room))))
;; NOTE: Ideally we would do this before sending the new request, but to make
;; the code much simpler, we do it afterward.
(with-current-buffer room-buffer
(when-let ((request-process (car (map-values ement-room-read-receipt-request))))
(when (process-live-p request-process)
(interrupt-process request-process)))
(setf ement-room-read-receipt-request nil
(alist-get read-event ement-room-read-receipt-request) request-process)))))))
(cl-defun ement-room-send-receipt (room session event &key (type "m.read"))
"Send receipt of TYPE for EVENT to ROOM on SESSION."
(pcase-let* (((cl-struct ement-room (id room-id)) room)
((cl-struct ement-event (id event-id)) event)
(endpoint (format "rooms/%s/receipt/%s/%s"
(url-hexify-string room-id) type
(url-hexify-string event-id))))
(ement-api session endpoint :method 'post :data "{}"
:then (pcase type
("m.read" (lambda (_data)
(ement-room-move-read-markers room
:read-event event)))
;; No other type is yet specified.
(_ #'ignore)))))
(cl-defun ement-room-move-read-markers
(room &key
(read-event (when-let ((event (alist-get "m.read" (ement-room-account-data room) nil nil #'equal)))
(map-nested-elt event '(content event_id))))
(fully-read-event (when-let ((event (alist-get "m.fully_read" (ement-room-account-data room) nil nil #'equal)))
(map-nested-elt event '(content event_id)))))
"Move read markers in ROOM to READ-EVENT and FULLY-READ-EVENT.
Each event may be an `ement-event' struct or an event ID. This
updates the markers in ROOM's buffer, not on the server; see
`ement-room-mark-read' for that."
(declare (indent defun))
(cl-labels ((update-marker (symbol to-event)
(let* ((old-node (symbol-value symbol))
(new-event-id (cl-etypecase to-event
(ement-event (ement-event-id to-event))
(string to-event)))
(event-node (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(and (ement-event-p data)
(equal (ement-event-id data) new-event-id)))))
(inhibit-read-only t))
(with-silent-modifications
(when old-node
(ewoc-delete ement-ewoc old-node))
(set symbol (when event-node
;; If the event hasn't been inserted into the buffer yet,
;; this might be nil. That shouldn't happen, but...
(ewoc-enter-after ement-ewoc event-node symbol)))))))
(when-let ((buffer (alist-get 'buffer (ement-room-local room))))
;; MAYBE: Error if no buffer? Or does it matter?
(with-current-buffer buffer
(when read-event
(update-marker 'ement-room-read-receipt-marker read-event))
(when fully-read-event
(update-marker 'ement-room-fully-read-marker fully-read-event))))
;; NOTE: Return nil so that, in the event this function is called manually with `eval-expression',
;; it does not cause an error due to the return value being an EWOC node, which is a structure too
;; big and/or circular to print. (This was one of those bugs that only happens WHEN debugging.)
nil))
;;;;; EWOC
(cl-defun ement-room--ewoc-next-matching (ewoc node pred &optional (move-fn #'ewoc-next))
"Return the next node in EWOC after NODE that PRED is true of.
PRED is called with node's data. Moves to next node by MOVE-FN."
(declare (indent defun))
(cl-loop do (setf node (funcall move-fn ewoc node))
until (or (null node)
(funcall pred (ewoc-data node)))
finally return node))
(defun ement-room--ewoc-last-matching (ewoc predicate)
"Return the last node in EWOC matching PREDICATE.
PREDICATE is called with node's data. Searches backward from
last node."
(declare (indent defun))
;; Intended to be like `ewoc-collect', but returning as soon as a match is found.
(cl-loop with node = (ewoc-nth ewoc -1)
while node
when (funcall predicate (ewoc-data node))
return node
do (setf node (ewoc-prev ewoc node))))
(defun ement-room--ewoc-collect-nodes (ewoc predicate)
"Collect all nodes in EWOC matching PREDICATE.
PREDICATE is called with the full node."
;; Intended to be like `ewoc-collect', but working with the full node instead of just the node's data.
(cl-loop with node = (ewoc-nth ewoc 0)
do (setf node (ewoc-next ewoc node))
while node
when (funcall predicate node)
collect node))
(defun ement-room--insert-ts-headers (&optional start-node end-node)
"Insert timestamp headers into current buffer's `ement-ewoc'.
Inserts headers between START-NODE and END-NODE, which default to
the first and last nodes in the buffer, respectively."
(let* ((type-predicate (lambda (node-data)
(and (ement-event-p node-data)
(not (equal "m.room.member" (ement-event-type node-data))))))
(ewoc ement-ewoc)
(end-node (or end-node
(ewoc-nth ewoc -1)))
(end-pos (if end-node
(ewoc-location end-node)
;; HACK: Trying to work around a bug in case the
;; room doesn't seem to have any events yet.
(point-max)))
(node-b (or start-node (ewoc-nth ewoc 0)))
node-a)
;; On the first loop iteration, node-a is set to the first matching
;; node after node-b; then it's set to the first node after node-a.
(while (and (setf node-a (ement-room--ewoc-next-matching ewoc (or node-a node-b) type-predicate)
node-b (when node-a
(ement-room--ewoc-next-matching ewoc node-a type-predicate)))
(not (or (> (ewoc-location node-a) end-pos)
(when node-b
(> (ewoc-location node-b) end-pos)))))
(cl-labels ((format-event
(event) (format "TS:%S (%s) Sender:%s Message:%S"
(/ (ement-event-origin-server-ts (ewoc-data event)) 1000)
(format-time-string "%Y-%m-%d %H:%M:%S"
(/ (ement-event-origin-server-ts (ewoc-data event)) 1000))
(ement-user-id (ement-event-sender (ewoc-data event)))
(when (alist-get 'body (ement-event-content (ewoc-data event)))
(substring-no-properties
(truncate-string-to-width (alist-get 'body (ement-event-content (ewoc-data event))) 20))))))
(ement-debug "Comparing event timestamps:"
(list 'A (format-event node-a))
(list 'B (format-event node-b))))
;; NOTE: Matrix timestamps are in milliseconds.
(let* ((a-ts (/ (ement-event-origin-server-ts (ewoc-data node-a)) 1000))
(b-ts (/ (ement-event-origin-server-ts (ewoc-data node-b)) 1000))
(diff-seconds (- b-ts a-ts))
(ement-room-timestamp-header-format ement-room-timestamp-header-format))
(when (and (>= diff-seconds ement-room-timestamp-header-delta)
(not (when-let ((node-after-a (ewoc-next ewoc node-a)))
(pcase (ewoc-data node-after-a)
(`(ts . ,_) t)
((or 'ement-room-read-receipt-marker 'ement-room-fully-read-marker) t)))))
(unless (equal (time-to-days a-ts) (time-to-days b-ts))
;; Different date: bind format to print date.
(let ((ement-room-timestamp-header-format ement-room-timestamp-header-with-date-format))
;; Insert the date-only header.
(setf node-a (ewoc-enter-after ewoc node-a (list 'ts b-ts)))))
(with-silent-modifications
;; Avoid marking a buffer as modified just because we inserted a ts
;; header (this function may be called after other events which shouldn't
;; cause it to be marked modified, like moving the read markers).
(ewoc-enter-after ewoc node-a (list 'ts b-ts))))))))
(cl-defun ement-room--insert-sender-headers
(ewoc &optional (start-node (ewoc-nth ewoc 0)) (end-node (ewoc-nth ewoc -1)))
;; TODO: Use this in appropriate places.
"Insert sender headers into EWOC.
Inserts headers between START-NODE and END-NODE, which default to
the first and last nodes in the buffer, respectively."
(cl-labels ((read-marker-p
(data) (member data '(ement-room-fully-read-marker
ement-room-read-receipt-marker)))
(message-event-p
(data) (and (ement-event-p data)
(equal "m.room.message" (ement-event-type data))))
(insert-sender-before
(node) (ewoc-enter-before ewoc node (ement-event-sender (ewoc-data node)))))
(let* ((event-node (if (ement-event-p (ewoc-data start-node))
start-node
(ement-room--ewoc-next-matching ewoc start-node
#'ement-event-p)))
(prev-node (when event-node
;; Just in case...
(ewoc-prev ewoc event-node))))
(while (and event-node
;; I don't like looking up the location of these nodes on every loop
;; iteration, but it seems like the only reliable way to determine
;; whether we've reached the end node. However, when this function is
;; called for short batches of events (or even a single event, like when
;; called from `ement-room--insert-event'), the overhead should be
;; minimal.
(<= (ewoc-location event-node) (ewoc-location end-node)))
(when (message-event-p (ewoc-data event-node))
(if (not prev-node)
;; No previous node and event is a message: insert header.
(insert-sender-before event-node)
;; Previous node exists.
(when (read-marker-p (ewoc-data prev-node))
;; Previous node is a read marker: we want to act as if they don't exist, so
;; we set `prev-node' to the non-marker node before it.
(setf prev-node (ement-room--ewoc-next-matching ewoc prev-node
(lambda (data)
(not (read-marker-p data)))
#'ewoc-prev)))
(when prev-node
;; A previous node still exists: maybe we need to add a header.
(cl-typecase (ewoc-data prev-node)
(ement-event
;; Previous node is an event.
(when (and (message-event-p (ewoc-data prev-node))
(not (equal (ement-event-sender (ewoc-data prev-node))
(ement-event-sender (ewoc-data event-node)))))
;; Previous node is a message event with a different sender: insert
;; header.
(insert-sender-before event-node)))
((or ement-user ement-room-membership-events)
;; Previous node is a user or coalesced membership events: do not insert
;; header.
nil)
(t
;; Previous node is not an event and not a read marker: insert header.
(insert-sender-before event-node))))))
(setf event-node (ement-room--ewoc-next-matching ewoc event-node
#'ement-event-p)
prev-node (when event-node
(ewoc-prev ewoc event-node)))))))
(defun ement-room--coalesce-nodes (a b ewoc)
"Try to coalesce events in nodes A and B in EWOC.
Return absorbing node if coalesced."
(cl-labels ((coalescable-p
(node) (or (and (ement-event-p (ewoc-data node))
(member (ement-event-type (ewoc-data node)) '("m.room.member")))
(ement-room-membership-events-p (ewoc-data node)))))
(when (and (coalescable-p a) (coalescable-p b))
(let* ((absorbing-node (if (or (ement-room-membership-events-p (ewoc-data a))
(not (ement-room-membership-events-p (ewoc-data b))))
a b))
(absorbed-node (if (eq absorbing-node a) b a)))
(cl-etypecase (ewoc-data absorbing-node)
(ement-room-membership-events nil)
(ement-event (setf (ewoc-data absorbing-node) (ement-room-membership-events--update
(make-ement-room-membership-events
:events (list (ewoc-data absorbing-node)))))))
(push (ewoc-data absorbed-node) (ement-room-membership-events-events (ewoc-data absorbing-node)))
(ement-room-membership-events--update (ewoc-data absorbing-node))
(ewoc-delete ewoc absorbed-node)
(ewoc-invalidate ewoc absorbing-node)
absorbing-node))))
(defun ement-room--insert-event (event)
"Insert EVENT into current buffer."
(cl-labels ((format-event
(event) (format "TS:%S (%s) Sender:%s Message:%S"
(/ (ement-event-origin-server-ts event) 1000)
(format-time-string "%Y-%m-%d %H:%M:%S"
(/ (ement-event-origin-server-ts event) 1000))
(ement-user-id (ement-event-sender event))
(when (alist-get 'body (ement-event-content event))
(substring-no-properties
(truncate-string-to-width (alist-get 'body (ement-event-content event)) 20)))))
(find-node-if
(ewoc pred &key (move #'ewoc-prev) (start (ewoc-nth ewoc -1)))
"Return node in EWOC whose data matches PRED.
Search starts from node START and moves by NEXT."
(cl-loop for node = start then (funcall move ewoc node)
while node
when (funcall pred (ewoc-data node))
return node))
(timestamped-node-p (data)
(pcase data
((pred ement-event-p) t)
((pred ement-room-membership-events-p) t)
(`(ts . ,_) t)))
(node-ts (data)
(pcase data
((pred ement-event-p) (ement-event-origin-server-ts data))
((pred ement-room-membership-events-p)
;; Not sure whether to use earliest or latest ts; let's try this for now.
(ement-room-membership-events-earliest-ts data))
(`(ts ,ts)
;; Matrix server timestamps are in ms, so we must convert back.
(* 1000 ts))))
(node< (a b)
"Return non-nil if event A's timestamp is before B's."
(< (node-ts a) (node-ts b))))
(ement-debug "INSERTING NEW EVENT: " (format-event event))
(let* ((ewoc ement-ewoc)
(event-node-before (ement-room--ewoc-node-before ewoc event #'node< :pred #'timestamped-node-p))
new-node)
;; HACK: Insert after any read markers.
(cl-loop for node-after-node-before = (ewoc-next ewoc event-node-before)
while node-after-node-before
while (not (ement-event-p (ewoc-data node-after-node-before)))
do (setf event-node-before node-after-node-before))
(setf new-node (if (not event-node-before)
(progn
(ement-debug "No event before it: add first.")
(if-let ((first-node (ewoc-nth ewoc 0)))
(progn
(ement-debug "EWOC not empty.")
(if (and (ement-user-p (ewoc-data first-node))
(equal (ement-event-sender event)
(ewoc-data first-node)))
(progn
(ement-debug "First node is header for this sender: insert after it, instead.")
(setf event-node-before first-node)
(ewoc-enter-after ewoc first-node event))
(ement-debug "First node is not header for this sender: insert first.")
(ewoc-enter-first ewoc event)))
(ement-debug "EWOC empty: add first.")
(ewoc-enter-first ewoc event)))
(ement-debug "Found event before new event: insert after it.")
(when-let ((next-node (ewoc-next ewoc event-node-before)))
(when (and (ement-user-p (ewoc-data next-node))
(equal (ement-event-sender event)
(ewoc-data next-node)))
(ement-debug "Next node is header for this sender: insert after it, instead.")
(setf event-node-before next-node)))
(ement-debug "Inserting after event"
;; NOTE: `format-event' is only for debugging, and it
;; doesn't handle user headers, so commenting it out or now.
;; (format-event (ewoc-data event-node-before))
;; NOTE: And it's *Very Bad* to pass the raw node data
;; to `ement-debug', because it makes event insertion
;; *Very Slow*. So we just comment that out for now.
;; (ewoc-data event-node-before)
)
(ewoc-enter-after ewoc event-node-before event)))
(when ement-room-coalesce-events
;; Try to coalesce events.
;; TODO: Move this to a separate function and call it from where this function is called.
(setf new-node (or (when event-node-before
(ement-room--coalesce-nodes event-node-before new-node ewoc))
(when (ewoc-next ewoc new-node)
(ement-room--coalesce-nodes new-node (ewoc-next ewoc new-node) ewoc))
new-node)))
(when ement-room-sender-in-headers
(ement-room--insert-sender-headers ewoc new-node new-node))
;; Return new node.
new-node)))
(defun ement-room--replace-event (new-event)
"Replace appropriate event with NEW-EVENT in current buffer.
If replaced event is not found, return nil, otherwise non-nil."
(let* ((ewoc ement-ewoc)
(old-event-node (ement-room--ewoc-last-matching ewoc
(lambda (data)
(cl-typecase data
(ement-event (ement--events-equal-p data new-event)))))))
(when old-event-node
;; TODO: Record old events in new event's local data, and make it accessible when inspecting the new event.
(let ((node-before (ewoc-prev ewoc old-event-node))
(inhibit-read-only t))
(ewoc-delete ewoc old-event-node)
(if node-before
(ewoc-enter-after ewoc node-before new-event)
(ewoc-enter-first ewoc new-event))))))
(cl-defun ement-room--ewoc-node-before (ewoc data <-fn
&key (from 'last) (pred #'identity))
"Return node in EWOC that matches PRED and belongs before DATA by <-FN.
Search from FROM (either `first' or `last')."
(cl-assert (member from '(first last)))
(if (null (ewoc-nth ewoc 0))
(ement-debug "EWOC is empty: returning nil.")
(ement-debug "EWOC has data: add at appropriate place.")
(cl-labels ((next-matching
(ewoc node next-fn pred) (cl-loop do (setf node (funcall next-fn ewoc node))
until (or (null node)
(funcall pred (ewoc-data node)))
finally return node)))
(let* ((next-fn (pcase from ('first #'ewoc-next) ('last #'ewoc-prev)))
(start-node (ewoc-nth ewoc (pcase from ('first 0) ('last -1)))))
(unless (funcall pred (ewoc-data start-node))
(setf start-node (next-matching ewoc start-node next-fn pred)))
(if (funcall <-fn (ewoc-data start-node) data)
(progn
(ement-debug "New data goes before start node.")
start-node)
(ement-debug "New data goes after start node: find node before new data.")
(let ((compare-node start-node))
(cl-loop while (setf compare-node (next-matching ewoc compare-node next-fn pred))
until (funcall <-fn (ewoc-data compare-node) data)
finally return (if compare-node
(progn
(ement-debug "Found place: enter there.")
compare-node)
(ement-debug "Reached end of collection: insert there.")
(pcase from
('first (ewoc-nth ewoc -1))
('last nil))))))))))
;;;;; Formatting
(defun ement-room--pp-thing (thing)
"Pretty-print THING.
To be used as the pretty-printer for `ewoc-create'. THING may be
an `ement-event' or `ement-user' struct, or a list like `(ts
TIMESTAMP)', where TIMESTAMP is a Unix timestamp number of
seconds."
;; TODO: Use handlers to insert so e.g. membership events can be inserted silently.
;; TODO: Use `cl-defmethod' and define methods for each of these THING types. (I've
;; benchmarked thoroughly and found no difference in performance between using
;; `cl-defmethod' and using a `defun' with `pcase', so as long as the `cl-defmethod'
;; specializer is sufficient, I see no reason not to use it.)
(pcase-exhaustive thing
((pred ement-event-p)
(insert "" (ement-room--format-event thing ement-room ement-session)))
((pred ement-user-p)
(insert (propertize (ement--format-user thing)
'display ement-room-username-display-property)))
(`(ts ,(and (pred numberp) ts)) ;; Insert a date header.
(let* ((string (format-time-string ement-room-timestamp-header-format ts))
(width (string-width string))
(maybe-newline (if (equal ement-room-timestamp-header-format ement-room-timestamp-header-with-date-format)
;; HACK: Rather than using another variable, compare the format strings to
;; determine whether the date is changing: if so, add a newline before the header.
(progn
(cl-incf width 3)
"\n")
""))
(alignment-space (pcase ement-room-timestamp-header-align
('right (propertize " "
'display `(space :align-to (- text ,(1+ width)))))
('center (propertize " "
'display `(space :align-to (- center ,(/ (1+ width) 2)))))
(_ " "))))
(insert maybe-newline
alignment-space
(propertize string
'face 'ement-room-timestamp-header))))
((or 'ement-room-read-receipt-marker 'ement-room-fully-read-marker)
(insert (propertize " "
'display '(space :width text :height (1))
'face thing)))
((pred ement-room-membership-events-p)
(let ((formatted-events (ement-room--format-membership-events thing ement-room)))
(add-face-text-property 0 (length formatted-events)
'ement-room-membership 'append formatted-events)
(insert (ement-room-wrap-prefix formatted-events))))))
;; (defun ement-room--format-event (event)
;; "Format `ement-event' EVENT."
;; (pcase-let* (((cl-struct ement-event sender type content origin-server-ts) event)
;; ((map body format ('formatted_body formatted-body)) content)
;; (ts (/ origin-server-ts 1000)) ; Matrix timestamps are in milliseconds.
;; (body (if (not formatted-body)
;; body
;; (pcase format
;; ("org.matrix.custom.html"
;; (ement-room--render-html formatted-body))
;; (_ (format "[unknown formatted-body format: %s] %s" format body)))))
;; (timestamp (propertize
;; " " 'display `((margin left-margin)
;; ,(propertize (format-time-string ement-room-timestamp-format ts)
;; 'face 'ement-room-timestamp))))
;; (body-face (pcase type
;; ("m.room.member" 'ement-room-membership)
;; (_ (if (equal (ement-user-id sender)
;; (ement-user-id (ement-session-user ement-session)))
;; 'ement-room-self-message 'default))))
;; (string (pcase type
;; ("m.room.message" body)
;; ("m.room.member" "")
;; (_ (format "[unknown event-type: %s] %s" type body)))))
;; (add-face-text-property 0 (length body) body-face 'append body)
;; (prog1 (concat timestamp string)
;; ;; Hacky or elegant? We return the string, but for certain event
;; ;; types, we also insert a widget (this function is called by
;; ;; EWOC with point at the insertion position). Seems to work...
;; (pcase type
;; ("m.room.member"
;; (widget-create 'ement-room-membership
;; :button-face 'ement-room-membership
;; :value (list (alist-get 'membership content))))))))
(defun ement-room--format-event (event room session)
"Return EVENT in ROOM on SESSION formatted.
Formats according to `ement-room-message-format-spec', which see."
(concat (pcase (ement-event-type event)
;; TODO: Define these with a macro, like the defevent and format-spec ones.
("m.room.message" (ement-room--format-message event room session))
("m.room.member"
(widget-create 'ement-room-membership
:button-face 'ement-room-membership
:value event)
"")
("m.reaction"
;; Handled by defevent-based handler.
"")
("m.room.avatar"
(ement-room-wrap-prefix
(format "%s changed the room's avatar."
(propertize (ement--user-displayname-in room (ement-event-sender event))
'help-echo (ement-user-id (ement-event-sender event))))
'face 'ement-room-membership))
("m.room.power_levels"
(ement-room--format-power-levels-event event room session))
("m.room.canonical_alias"
(ement-room--format-canonical-alias-event event room session))
(_ (ement-room-wrap-prefix
(format "[sender:%s type:%s]"
(ement-user-id (ement-event-sender event))
(ement-event-type event))
'help-echo (format "%S" (ement-event-content event)))))
(propertize " "
'display ement-room-event-separator-display-property)))
(defun ement-room--format-reactions (event)
"Return formatted reactions to EVENT."
;; TODO: Like other events, pop to a buffer showing the raw reaction events when a key is pressed.
(if-let ((reactions (map-elt (ement-event-local event) 'reactions)))
(cl-labels ((format-reaction
(ks) (pcase-let* ((`(,key . ,senders) ks)
(key (propertize key 'face 'ement-room-reactions-key))
(count (propertize (format " (%s)" (length senders))
'face 'ement-room-reactions))
(string
(propertize (concat key count)
'button '(t)
'category 'default-button
'action #'ement-room-reaction-button-action
'follow-link t
'help-echo (lambda (_window buffer _pos)
;; NOTE: If the reaction key string is a Unicode character composed
;; with, e.g. "VARIATION SELECTOR-16", `string-to-char' ignores the
;; composed modifier/variation-selector and just returns the first
;; character of the string. This should be fine, since it's just
;; for the tooltip.
(concat
(get-char-code-property (string-to-char key) 'name) ": "
(senders-names senders (buffer-local-value 'ement-room buffer))))))
(local-user-p (cl-member (ement-user-id (ement-session-user ement-session)) senders
:key #'ement-user-id :test #'equal)))
(when local-user-p
(add-face-text-property 0 (length string) '(:box (:style pressed-button) :inverse-video t)
nil string))
(ement--remove-face-property string 'button)
string))
(senders-names
(senders room) (cl-loop for sender in senders
collect (ement--user-displayname-in room sender)
into names
finally return (string-join names ", "))))
(cl-loop with keys-senders
for reaction in reactions
for key = (map-nested-elt (ement-event-content reaction) '(m.relates_to key))
for sender = (ement-event-sender reaction)
do (push sender (alist-get key keys-senders nil nil #'string=))
finally do (setf keys-senders (cl-sort keys-senders #'> :key (lambda (pair) (length (cdr pair)))))
finally return (concat "\n " (mapconcat #'format-reaction keys-senders " "))))
""))
(cl-defun ement-room--format-message (event room session &optional (format ement-room-message-format-spec))
"Return EVENT in ROOM on SESSION formatted according to FORMAT.
Format defaults to `ement-room-message-format-spec', which see."
;; Bind this locally so formatters can modify it for this call.
(let ((ement-room--format-message-margin-p)
(left-margin-width ement-room-left-margin-width)
(right-margin-width ement-room-right-margin-width))
;; Copied from `format-spec'.
(with-temp-buffer
;; Pretend this is a room buffer.
(setf ement-session session
ement-room room)
;; HACK: Setting these buffer-locally in a temp buffer is ugly.
(setq-local ement-room-left-margin-width left-margin-width)
(setq-local ement-room-right-margin-width right-margin-width)
(insert format)
(goto-char (point-min))
(while (search-forward "%" nil t)
(cond
((eq (char-after) ?%)
;; Quoted percent sign.
(delete-char 1))
((looking-at "\\([-0-9.]*\\)\\([a-zA-Z]\\)")
;; Valid format spec.
(let* ((num (match-string 1))
(spec (string-to-char (match-string 2)))
(_
;; We delete the specifier now, because the formatter may change the
;; match data, and we already have what we need.
(delete-region (1- (match-beginning 0)) (match-end 0)))
(formatter (or (alist-get spec ement-room-event-formatters)
(error "Invalid format character: `%%%c'" spec)))
(val (or (funcall formatter event room session)
(let ((print-level 1))
(propertize (format "[Event has no value for spec \"?%s\"]" (char-to-string spec))
'face 'font-lock-comment-face
'help-echo (format "%S" event)))))
;; Pad result to desired length.
(text (format (concat "%" num "s") val)))
(insert text)))
(t
;; Signal an error on bogus format strings.
(error "ement-room--format-message: Invalid format string: %S" format))))
;; Propertize margin text.
(when ement-room--format-message-wrap-prefix
(when-let ((wrap-prefix-end (next-single-property-change (point-min) 'wrap-prefix-end)))
(let* ((prefix-width (string-width
(buffer-substring-no-properties (point-min) wrap-prefix-end)))
(prefix (propertize " " 'display `((space :width ,prefix-width)))))
(goto-char wrap-prefix-end)
(delete-char 1)
;; We apply the prefix to the entire event as `wrap-prefix', and to just the
;; body as `line-prefix'.
(put-text-property (point-min) (point-max) 'wrap-prefix prefix)
(put-text-property (point) (point-max) 'line-prefix prefix))))
(when ement-room--format-message-margin-p
(when-let ((left-margin-end (next-single-property-change (point-min) 'left-margin-end)))
(goto-char left-margin-end)
(delete-char 1)
(let ((left-margin-text-width (string-width (buffer-substring-no-properties (point-min) (point)))))
;; It would be preferable to not have to allocate a string to
;; calculate the display width, but I don't know of another way.
(put-text-property (point-min) (point)
'display `((margin left-margin)
,(buffer-substring (point-min) (point))))
(save-excursion
(goto-char (point-min))
;; Insert a string with a display specification that causes it to be displayed in the
;; left margin as a space that displays with the width of the difference between the
;; left margin's width and the display width of the text in the left margin (whew).
;; This is complicated, but it seems to work (minus a possible Emacs/Gtk bug that
;; sometimes causes the space to have a little "junk" displayed in it at times, but
;; that's not our fault). (And this is another example of how well-documented Emacs
;; is: this was only possible by carefully reading the Elisp manual.)
(insert (propertize " " 'display `((margin left-margin)
(space :width (- left-margin ,left-margin-text-width))))))))
(when-let ((right-margin-start (next-single-property-change (point-min) 'right-margin-start)))
(goto-char right-margin-start)
(delete-char 1)
(let ((string (buffer-substring (point) (point-max))))
;; Relocate its text to the beginning so it won't be
;; displayed at the last line of wrapped messages.
(delete-region (point) (point-max))
(goto-char (point-min))
(insert-and-inherit
(propertize " "
'display `((margin right-margin) ,string))))))
(buffer-string))))
(cl-defun ement-room--format-message-body (event &key (formatted-p t))
"Return formatted body of \"m.room.message\" EVENT.
If FORMATTED-P, return the formatted body content, when available."
(pcase-let* (((cl-struct ement-event content
(unsigned (map ('redacted_by unsigned-redacted-by)))
(local (map ('redacted-by local-redacted-by))))
event)
((map ('body main-body) msgtype ('format content-format) ('formatted_body formatted-body)
('m.relates_to (map ('rel_type rel-type)))
('m.new_content (map ('body new-body) ('formatted_body new-formatted-body)
('format new-content-format))))
content)
(body (or new-body main-body))
(formatted-body (or new-formatted-body formatted-body))
(body (if (or (not formatted-p) (not formatted-body))
;; Copy the string so as not to add face properties to the one in the struct.
(copy-sequence body)
(pcase (or new-content-format content-format)
("org.matrix.custom.html"
(save-match-data
(ement-room--render-html formatted-body)))
(_ (format "[unknown body format: %s] %s"
(or new-content-format content-format) body)))))
(appendix (pcase msgtype
;; TODO: Face for m.notices.
((or "m.text" "m.emote" "m.notice") nil)
("m.image" (ement-room--format-m.image event))
("m.file" (ement-room--format-m.file event))
("m.video" (ement-room--format-m.video event))
(_ (if (or local-redacted-by unsigned-redacted-by)
nil
(format "[unsupported msgtype: %s]" msgtype ))))))
(when body
;; HACK: Once I got an error when body was nil, so let's avoid that.
(setf body (ement-room--linkify-urls body)))
;; HACK: Ensure body isn't nil (e.g. redacted messages can have empty bodies).
(unless body
(setf body (copy-sequence
;; Yes, copying this string is necessary here too, otherwise a single
;; string will be used across every call to this function, whose face
;; properties will be added to every time in other functions, which will
;; make a very big mess of face properties if a room's buffer is opened
;; and closed a few times.
(if (or local-redacted-by unsigned-redacted-by)
"[redacted]"
"[message has no body content]"))))
(when appendix
(setf body (concat body " " appendix)))
(when (equal "m.replace" rel-type)
;; Message is an edit.
(setf body (concat body " " (propertize "[edited]" 'face 'font-lock-comment-face))))
body))
(defun ement-room--render-html (string)
"Return rendered version of HTML STRING.
HTML is rendered to Emacs text using `shr-insert-document'."
(with-temp-buffer
(insert string)
(save-excursion
;; NOTE: We workaround `shr`'s not indenting the blockquote properly (it
;; doesn't seem to compensate for the margin). I don't know exactly how
;; `shr-tag-blockquote' and `shr-mark-fill' and `shr-fill-line' and
;; `shr-indentation' work together, but through trial-and-error, this
;; seems to work. It even seems to work properly when a window is
;; resized (i.e. the wrapping is adjusted automatically by redisplay
;; rather than requiring the message to be re-rendered to HTML).
(let ((shr-use-fonts ement-room-shr-use-fonts)
(old-fn (symbol-function 'shr-tag-blockquote))) ;; Bind to a var to avoid unknown-function linting errors.
(cl-letf (((symbol-function 'shr-fill-line) #'ignore)
((symbol-function 'shr-tag-blockquote)
(lambda (dom)
(let ((beg (point-marker)))
(funcall old-fn dom)
(add-text-properties beg (point-max)
'(wrap-prefix " "
line-prefix " "))))))
(shr-insert-document
(libxml-parse-html-region (point-min) (point-max))))))
(string-trim (buffer-substring (point) (point-max)))))
(cl-defun ement-room--event-mentions-user-p (event user &optional (room ement-room))
"Return non-nil if EVENT in ROOM mentions USER."
(pcase-let* (((cl-struct ement-event content) event)
((map body formatted_body) content)
(body (or formatted_body body)))
;; FIXME: `ement--user-displayname-in' may not be returning the right result for the
;; local user, so test the displayname slot too. (But even that may be nil sometimes?
;; Something needs to be fixed...)
;; HACK: So we use the username slot, which was created just for this, for now.
(when body
(cl-macrolet ((matches-body-p
(form) `(when-let ((string ,form))
(string-match-p (regexp-quote string) body))))
(or (matches-body-p (ement-user-username user))
(matches-body-p (ement--user-displayname-in room user))
(matches-body-p (ement-user-id user)))))))
(defun ement-room--linkify-urls (string)
"Return STRING with URLs in it made clickable."
;; Is there an existing Emacs function to do this? I couldn't find one.
;; Yes, maybe: `goto-address-mode'. TODO: Try goto-address-mode.
(with-temp-buffer
(insert string)
(goto-char (point-min))
(cl-loop while (re-search-forward (rx bow "http" (optional "s") "://" (1+ (not space)))
nil 'noerror)
do (make-text-button (match-beginning 0) (match-end 0)
'mouse-face 'highlight
'face 'link
'help-echo (match-string 0)
'action #'browse-url-at-mouse
'follow-link t))
(buffer-string)))
;; NOTE: This function is not useful when displaynames are shown in the margin, because
;; margins are not mouse-interactive in Emacs, therefore the help-echo function is called
;; with the string and the position in the string, which leaves the buffer position
;; unknown. So we have to set the help-echo to a string rather than a function. But the
;; function may be useful in the future, so leaving it commented for now.
;; (defun ement-room--user-help-echo (window _object pos)
;; "Return user ID string for POS in WINDOW.
;; For use as a `help-echo' function on `ement-user' headings."
;; (let ((data (with-selected-window window
;; (ewoc-data (ewoc-locate ement-ewoc pos)))))
;; (cl-typecase data
;; (ement-event (ement-user-id (ement-event-sender data)))
;; (ement-user (ement-user-id data)))))
(defun ement-room--user-color (user)
"Return a color in which to display USER's messages."
(cl-labels ((relative-luminance
;; Copy of `modus-themes-wcag-formula', an elegant
;; implementation by Protesilaos Stavrou. Also see
;; <https://en.wikipedia.org/wiki/Relative_luminance> and
;; <https://www.w3.org/TR/WCAG20/#relativeluminancedef>.
(rgb) (cl-loop for k in '(0.2126 0.7152 0.0722)
for x in rgb
sum (* k (if (<= x 0.03928)
(/ x 12.92)
(expt (/ (+ x 0.055) 1.055) 2.4)))))
(contrast-ratio
;; Copy of `modus-themes-contrast'; see above.
(a b) (let ((ct (/ (+ (relative-luminance a) 0.05)
(+ (relative-luminance b) 0.05))))
(max ct (/ ct))))
(increase-contrast
(color against target toward)
(let ((gradient (cdr (color-gradient color toward 20)))
new-color)
(cl-loop do (setf new-color (pop gradient))
while new-color
until (>= (contrast-ratio new-color against) target)
;; Avoid infinite loop in case of weirdness
;; by returning color as a fallback.
finally return (or new-color color)))))
(let* ((id (ement-user-id user))
(id-hash (float (+ (abs (sxhash id)) ement-room-prism-color-adjustment)))
;; TODO: Wrap-around the value to get the color I want.
(ratio (/ id-hash (float most-positive-fixnum)))
(color-num (round (* (* 255 255 255) ratio)))
(color-rgb (list (/ (float (logand color-num 255)) 255)
(/ (float (lsh (logand color-num 65280) -8)) 255)
(/ (float (lsh (logand color-num 16711680) -16)) 255)))
(background-rgb (color-name-to-rgb (face-background 'default))))
(when (< (contrast-ratio color-rgb background-rgb) ement-room-prism-minimum-contrast)
(setf color-rgb (increase-contrast color-rgb background-rgb ement-room-prism-minimum-contrast
(color-name-to-rgb (face-foreground 'default)))))
(apply #'color-rgb-to-hex (append color-rgb (list 2))))))
;;;;; Compose buffer
;; Compose messages in a separate buffer, like `org-edit-special'.
(defvar-local ement-room-compose-buffer nil
"Non-nil in buffers that are composing a message to a room.")
(cl-defun ement-room-compose-message (room session &key body)
"Compose a message to ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room. With BODY, use it as the initial
message contents."
(interactive
(ement-with-room-and-session
(list ement-room ement-session)))
(let* ((compose-buffer (generate-new-buffer (format "*Ement compose: %s*" (ement--room-display-name ement-room))))
(send-message-filter ement-room-send-message-filter))
(with-current-buffer compose-buffer
(ement-room-init-compose-buffer room session)
(setf ement-room-send-message-filter send-message-filter)
;; TODO: Make mode configurable.
(when body
(insert body))
;; FIXME: Inexplicably, this doesn't do anything, so we comment it out for now.
;; (add-function :override (local 'org-mode)
;; ;; HACK: Since `org-mode' kills buffer-local variables we need, we add
;; ;; buffer-local advice to prevent that from happening in case a user enables it.
;; (lambda (&rest _ignore)
;; (message "Use `ement-room-compose-org' to activate Org in this buffer")))
;; NOTE: Surprisingly, we don't run this hook in `ement-room-init-compose-buffer',
;; because if a function in that hook calls the init function (like
;; `ement-room-compose-org' does), it makes `run-hooks' recursive. As long as this
;; is the only function that makes the compose buffer, and as long as none of the
;; hooks do anything that activating `org-mode' nullifies, this should be okay...
(run-hooks 'ement-room-compose-hook))
(pop-to-buffer compose-buffer)))
(defun ement-room-compose-from-minibuffer ()
"Edit the current message in a compose buffer.
To be called from a minibuffer opened from
`ement-room-read-string'."
(interactive)
(cl-assert (minibufferp)) (cl-assert ement-room) (cl-assert ement-session)
;; TODO: When requiring Emacs 27, use `letrec'.
;; HACK: I can't seem to find a better way to do this, to exit the minibuffer without exiting this command too.
(let* ((body (minibuffer-contents))
(compose-fn-symbol (gensym (format "ement-compose-%s" (or (ement-room-canonical-alias ement-room)
(ement-room-id ement-room)))))
(input-method current-input-method) ; Capture this value from the minibuffer.
(send-message-filter ement-room-send-message-filter)
(replying-to-event ement-room-replying-to-event)
(compose-fn (lambda ()
;; HACK: Since exiting the minibuffer restores the previous window configuration,
;; we have to do some magic to get the new compose buffer to appear.
;; TODO: Use letrec with Emacs 27.
(remove-hook 'minibuffer-exit-hook compose-fn-symbol)
;; FIXME: Probably need to unintern the symbol.
(ement-room-compose-message ement-room ement-session :body body)
;; FIXME: This doesn't propagate the send-message-filter to the minibuffer.
(setf ement-room-send-message-filter send-message-filter)
(setq-local ement-room-replying-to-event replying-to-event)
(when replying-to-event
(setq-local header-line-format
(concat header-line-format
(format " (Replying to message from %s)"
(ement--user-displayname-in
ement-room (ement-event-sender replying-to-event))))))
(let* ((compose-buffer (current-buffer))
(show-buffer-fn-symbol (gensym "ement-show-compose-buffer"))
(show-buffer-fn (lambda ()
(remove-hook 'window-configuration-change-hook show-buffer-fn-symbol)
;; FIXME: Probably need to unintern the symbol.
(pop-to-buffer compose-buffer)
(set-input-method input-method))))
(fset show-buffer-fn-symbol show-buffer-fn)
(add-hook 'window-configuration-change-hook show-buffer-fn-symbol)))))
(fset compose-fn-symbol compose-fn)
(add-hook 'minibuffer-exit-hook compose-fn-symbol)
;; Deactivate minibuffer's input method, otherwise subsequent
;; minibuffers will have it, too.
(deactivate-input-method)
(abort-recursive-edit)))
(defun ement-room-compose-send ()
"Prompt to send the current compose buffer's contents.
To be called from an `ement-room-compose' buffer."
(interactive)
(cl-assert ement-room-compose-buffer)
(cl-assert ement-room) (cl-assert ement-session)
;; Putting it in the kill ring seems like the best thing to do, to ensure
;; it doesn't get lost if the user exits the minibuffer before sending.
(kill-new (string-trim (buffer-string)))
(let ((room ement-room)
(session ement-session)
(input-method current-input-method)
(send-message-filter ement-room-send-message-filter)
(replying-to-event ement-room-replying-to-event))
(quit-restore-window nil 'kill)
(ement-view-room room session)
(let* ((prompt (format "Send message (%s): " (ement-room-display-name ement-room)))
(current-input-method input-method) ; Bind around read-string call.
(ement-room-send-message-filter send-message-filter)
(pos (when replying-to-event
(ewoc-location (ement-room--ewoc-last-matching ement-ewoc
(lambda (data)
(eq data replying-to-event))))))
(body (if replying-to-event
(ement-room-with-highlighted-event-at pos
(ement-room-read-string prompt (car kill-ring) nil nil 'inherit-input-method))
(ement-room-read-string prompt (car kill-ring) nil nil 'inherit-input-method)) ))
(ement-room-send-message ement-room ement-session :body body :replying-to-event replying-to-event))))
(defun ement-room-init-compose-buffer (room session)
"Eval BODY, setting up the current buffer as a compose buffer.
Sets ROOM and SESSION buffer-locally, binds `save-buffer' in
a copy of the local keymap, and sets `header-line-format'."
;; Using a macro for this seems awkward but necessary.
(setq-local ement-room room)
(setq-local ement-session session)
(setf ement-room-compose-buffer t)
(setq-local completion-at-point-functions
(append '(ement-room--complete-members-at-point ement-room--complete-rooms-at-point)
completion-at-point-functions))
;; FIXME: Compose with local map?
(use-local-map (if (current-local-map)
(copy-keymap (current-local-map))
(make-sparse-keymap)))
(local-set-key [remap save-buffer] #'ement-room-compose-send)
(setq header-line-format (substitute-command-keys
(format " Press \\[save-buffer] to send message to room (%s)"
(ement-room-display-name room)))))
;;;;; Widgets
(require 'widget)
(define-widget 'ement-room-membership 'item
"Widget for membership events."
;; FIXME: This makes it hard to add a timestamp according to the buffer's message format spec.
;; FIXME: The widget value inserts an extra space before the wrap prefix. There seems
;; to be no way to fix this while still using a widget for this, so maybe we shouldn't
;; use a widget after all. But it might be good to keep digging for a solution so that
;; widgets could be used for other things later...
:format "%{ %v %}"
:sample-face 'ement-room-membership
:value-create (lambda (widget)
(pcase-let* ((event (widget-value widget)))
(insert (ement-room-wrap-prefix
(ement-room--format-member-event event ement-room))))))
(defun ement-room--format-member-event (event room)
"Return formatted string for \"m.room.member\" EVENT in ROOM."
;; SPEC: Section 9.3.4: "m.room.member".
(pcase-let* (((cl-struct ement-event sender state-key
(content (map reason ('avatar_url new-avatar-url)
('membership new-membership) ('displayname new-displayname)))
(unsigned (map ('prev_content (map ('avatar_url old-avatar-url)
('membership prev-membership)
('displayname prev-displayname))))))
event)
(sender-name (ement--user-displayname-in ement-room sender)))
(cl-macrolet ((nes (var)
;; For "non-empty-string". Needed because the displayname can be
;; an empty string, but apparently is never null. (Note that the
;; argument should be a variable, never any other form, to avoid
;; multiple evaluation.)
`(when (and ,var (not (string-empty-p ,var)))
,var))
(sender-name-id-string
() `(propertize sender-name
'help-echo (ement-user-id sender)))
(new-displayname-sender-name-state-key-string
() `(propertize (or (nes new-displayname) (nes sender-name) (nes state-key))
'help-echo state-key))
(sender-name-state-key-string
() `(propertize sender-name
'help-echo state-key))
(prev-displayname-id-string
() `(propertize (or prev-displayname sender-name)
'help-echo (ement-user-id sender))))
(pcase-exhaustive new-membership
("invite"
(pcase prev-membership
((or "leave" '())
(format "%s invited %s"
(sender-name-id-string)
(new-displayname-sender-name-state-key-string)))
(_ (format "%s sent unrecognized invite event for %s"
(sender-name-id-string)
(new-displayname-sender-name-state-key-string)))))
("join"
(pcase prev-membership
("invite"
(format "%s accepted invitation to join"
(sender-name-state-key-string)))
("join"
(cond ((not (equal new-displayname prev-displayname))
(propertize (format "%s changed name to %s"
prev-displayname (or new-displayname (ement--user-displayname-in room sender)))
'help-echo state-key))
((not (equal new-avatar-url old-avatar-url))
(format "%s changed avatar"
(new-displayname-sender-name-state-key-string)))
(t (format "Unrecognized membership event for %s"
(sender-name-state-key-string)))))
("leave"
(format "%s rejoined"
(sender-name-state-key-string)))
(`nil
(format "%s joined"
(new-displayname-sender-name-state-key-string)))
(_ (format "%s sent unrecognized join event for %s"
(sender-name-id-string)
(new-displayname-sender-name-state-key-string)))))
("leave"
(pcase prev-membership
("invite"
(pcase state-key
((pred (equal (ement-user-id sender)))
(format "%s rejected invitation"
(sender-name-id-string)))
(_ (format "%s revoked %s's invitation"
(sender-name-id-string)
(new-displayname-sender-name-state-key-string)))))
("join"
(pcase state-key
((pred (equal (ement-user-id sender)))
(format "%s left%s"
(prev-displayname-id-string)
(if reason
(format " (%s)" reason)
"")))
(_ (format "%s kicked %s%s"
(sender-name-id-string)
(propertize (or prev-displayname state-key)
'help-echo state-key)
(if reason
(format " (%s)" reason)
"")))))
("ban"
(format "%s unbanned %s"
(sender-name-id-string)
state-key))
(_ (format "%s left%s"
(prev-displayname-id-string)
(if reason
(format " (%s)" reason)
"")))))
("ban"
(pcase prev-membership
((or "invite" "leave")
(format "%s banned %s%s"
(sender-name-id-string)
(propertize (or prev-displayname state-key)
'help-echo state-key)
(if reason
(format " (%s)" reason)
"")))
("join"
(format "%s kicked and banned %s%s"
(sender-name-id-string)
(propertize (or prev-displayname state-key)
'help-echo state-key)
(if reason
(format " (%s)" reason)
"")))
(_ (format "%s sent unrecognized ban event for %s"
(sender-name-id-string)
(propertize (or prev-displayname state-key)
'help-echo state-key)))))))))
;; NOTE: Widgets are only currently used for single membership events, not grouped ones.
(defun ement-room--format-membership-events (struct room)
"Return string for STRUCT in ROOM.
STRUCT should be an `ement-room-membership-events' struct."
(cl-labels ((event-user
(event) (propertize (if-let (user (gethash (ement-event-state-key event) ement-users))
(ement--user-displayname-in room user)
(ement-event-state-key event))
'help-echo (concat (ement-room--format-member-event event room)
" <" (ement-event-state-key event) ">")))
(old-membership (event) (map-nested-elt (ement-event-unsigned event) '(prev_content membership)))
(new-membership (event) (alist-get 'membership (ement-event-content event))))
(pcase-let* (((cl-struct ement-room-membership-events events) struct))
(pcase (length events)
(0 (warn "No events in `ement-room-membership-events' struct"))
(1 (ement-room--format-member-event (car events) room))
(_ (let* ((left-events (cl-remove-if-not (lambda (event)
(and (equal "leave" (new-membership event))
(not (member (old-membership event) '("ban" "invite")))))
events))
(join-events (cl-remove-if-not (lambda (event)
(and (equal "join" (new-membership event))
(not (equal "join" (old-membership event)))))
events))
(rejoin-events (cl-remove-if-not (lambda (event)
(and (equal "join" (new-membership event))
(equal "leave" (old-membership event))))
events))
(invite-events (cl-remove-if-not (lambda (event)
(equal "invite" (new-membership event)))
events))
(reject-events (cl-remove-if-not (lambda (event)
(and (equal "invite" (old-membership event))
(equal "leave" (new-membership event))))
events))
(ban-events (cl-remove-if-not (lambda (event)
(and (member (old-membership event) '("invite" "leave"))
(equal "ban" (new-membership event))))
events))
(unban-events (cl-remove-if-not (lambda (event)
(and (equal "ban" (old-membership event))
(equal "leave" (new-membership event))))
events))
(kick-and-ban-events (cl-remove-if-not (lambda (event)
(and (equal "join" (old-membership event))
(equal "ban" (new-membership event))))
events))
(rename-events (cl-remove-if-not (lambda (event)
(and (equal "join" (old-membership event))
(equal "join" (new-membership event))
(equal (alist-get 'avatar_url (ement-event-content event))
(map-nested-elt (ement-event-unsigned event)
'(prev_content avatar_url)))))
events))
(avatar-events (cl-remove-if-not (lambda (event)
(and (equal "join" (old-membership event))
(equal "join" (new-membership event))
(not (equal (alist-get 'avatar_url (ement-event-content event))
(map-nested-elt (ement-event-unsigned event)
'(prev_content avatar_url))))))
events))
join-and-leave-events rejoin-and-leave-events)
;; Remove apparent duplicates between join/rejoin events.
(setf join-events (cl-delete-if (lambda (event)
(cl-find (ement-event-state-key event) rejoin-events
:test #'equal :key #'ement-event-state-key))
join-events)
rejoin-events (cl-delete-if (lambda (event)
(cl-find (ement-event-state-key event) join-events
:test #'equal :key #'ement-event-state-key))
rejoin-events)
join-and-leave-events (cl-loop for join-event in join-events
for left-event = (cl-find (ement-event-state-key join-event) left-events
:test #'equal :key #'ement-event-state-key)
when left-event
collect left-event
and do (setf join-events (cl-delete (ement-event-state-key join-event) join-events
:test #'equal :key #'ement-event-state-key)
left-events (cl-delete (ement-event-state-key left-event) left-events
:test #'equal :key #'ement-event-state-key)))
rejoin-and-leave-events (cl-loop for rejoin-event in rejoin-events
for left-event = (cl-find (ement-event-state-key rejoin-event) left-events
:test #'equal :key #'ement-event-state-key)
when left-event
collect left-event
and do (setf rejoin-events (cl-delete
(ement-event-state-key rejoin-event) rejoin-events
:test #'equal :key #'ement-event-state-key)
left-events (cl-delete (ement-event-state-key left-event) left-events
:test #'equal :key #'ement-event-state-key))))
(format "Membership: %s."
(string-join (cl-loop for (type . events)
in (ement-alist "rejoined" rejoin-events
"joined" join-events
"left" left-events
"joined and left" join-and-leave-events
"rejoined and left" rejoin-and-leave-events
"invited" invite-events
"rejected invitation" reject-events
"banned" ban-events
"unbanned" unban-events
"kicked and banned" kick-and-ban-events
"changed name" rename-events
"changed avatar" avatar-events)
for users = (mapcar #'event-user
(cl-delete-duplicates
events :key #'ement-event-state-key))
for number = (length users)
when events
collect (format "%s %s (%s)" number
(propertize type 'face 'bold)
(string-join users ", ")))
"; "))))))))
;;;;; Images
;; Downloading and displaying images in messages, room/user avatars, etc.
(require 'image)
(defvar ement-room-image-keymap
(let ((map (make-sparse-keymap)))
(set-keymap-parent map image-map)
;; TODO: Make RET work for showing images too.
;; (define-key map (kbd "RET") #'ement-room-image-show)
(define-key map [mouse-1] #'ement-room-image-scale-mouse)
(define-key map [double-mouse-1] #'ement-room-image-show)
map)
"Keymap for images in room buffers.")
(defgroup ement-room-images nil
"Showing images in rooms."
:group 'ement-room)
(defcustom ement-room-images t
"Download and show images in messages, avatars, etc."
:type 'boolean
:set (lambda (option value)
(if (or (fboundp 'imagemagick-types)
(when (fboundp 'image-transforms-p)
(image-transforms-p)))
(set-default option value)
(set-default option nil)
(when (and value (display-images-p))
(display-warning 'ement "This Emacs was not built with ImageMagick support, nor does it support Cairo/XRender scaling, so images can't be displayed in Ement")))))
(defcustom ement-room-image-initial-height 0.2
"Limit images' initial display height.
If a number, it should be no larger than 1 (because Emacs can't
display images larger than the window body height)."
:type '(choice (const :tag "Use full window width" nil)
(number :tag "Limit to this multiple of the window body height")))
(defun ement-room-image-scale-mouse (event)
"Toggle scale of image at mouse EVENT.
Scale image to fit within the window's body. If image is already
fit to the window, reduce its max-height to 10% of the window's
height."
(interactive "e")
(pcase-let* ((`(,_type ,position ,_count) event)
(window (posn-window position))
(pos (event-start position)))
(with-selected-window window
(pcase-let* ((image (get-text-property pos 'display))
(window-width (window-body-width nil t))
(window-height (window-body-height nil t))
;; Image scaling commands set :max-height and friends to nil so use the
;; impossible dummy value -1. See <https://github.com/alphapapa/ement.el/issues/39>.
(new-height (if (= window-height (or (image-property image :max-height) -1))
(/ window-height 10)
window-height)))
(when (fboundp 'imagemagick-types)
;; Only do this when ImageMagick is supported.
;; FIXME: When requiring Emacs 27+, remove this (I guess?).
(setf (image-property image :type) 'imagemagick))
;; Set :scale to nil since image scaling commands might have changed it.
(setf (image-property image :scale) nil
(image-property image :max-width) window-width
(image-property image :max-height) new-height)))))
(defun ement-room-image-show (event)
"Show image at mouse EVENT in a new buffer."
(interactive "e")
(pcase-let* ((`(,_type ,position ,_count) event)
(window (posn-window position)))
(with-current-buffer (window-buffer window)
(pcase-let* ((pos (event-start position))
(image (copy-sequence (get-text-property pos 'display)))
(ement-event (ewoc-data (ewoc-locate ement-ewoc pos)))
((cl-struct ement-event id) ement-event)
(buffer-name (format "*Ement image: %s*" id))
(new-buffer (get-buffer-create buffer-name)))
(when (fboundp 'imagemagick-types)
;; Only do this when ImageMagick is supported.
;; FIXME: When requiring Emacs 27+, remove this (I guess?).
(setf (image-property image :type) 'imagemagick))
(setf (image-property image :scale) 1.0
(image-property image :max-width) nil
(image-property image :max-height) nil)
(with-current-buffer new-buffer
(erase-buffer)
(insert-image image))
(pop-to-buffer new-buffer '((display-buffer-pop-up-frame)))
(set-frame-parameter nil 'fullscreen 'maximized)))))
(defun ement-room--format-m.image (event)
"Return \"m.image\" EVENT formatted as a string.
When `ement-room-images' is non-nil, also download it and then
show it in the buffer."
(pcase-let* (((cl-struct ement-event content (local event-local)) event)
;; HACK: Get the room's buffer from the variable (the current buffer
;; will be a temp formatting buffer when this is called, but it still
;; inherits the `ement-room' variable from the room buffer, thankfully).
((cl-struct ement-room local) ement-room)
((map buffer) local)
;; TODO: Thumbnail support.
((map ('url mxc) info ;; ('thumbnail_url thumbnail-url)
) content)
((map thumbnail_info) info)
((map ('h _thumbnail-height) ('w _thumbnail-width)) thumbnail_info)
((map image) event-local)
(url (when mxc
(ement--mxc-to-url mxc ement-session)))
;; (thumbnail-url (ement--mxc-to-url thumbnail-url ement-session))
)
(if (and ement-room-images image)
;; Images enabled and image downloaded: create image and
;; return it in a string.
(condition-case err
(let ((image (create-image image nil 'data-p :ascent 'center))
(buffer-window (when buffer
(get-buffer-window buffer)))
max-height max-width)
;; Calculate max image display size.
(cond (ement-room-image-initial-height
;; Use configured value.
(setf max-height (truncate
;; Emacs doesn't like floats as the max-height.
(* (window-body-height buffer-window t)
ement-room-image-initial-height))
max-width (window-body-width buffer-window t)))
(buffer-window
;; Buffer displayed: use window size.
(setf max-height (window-body-height buffer-window t)
max-width (window-body-width buffer-window t)))
(t
;; Buffer not displayed: use frame size.
(setf max-height (frame-pixel-height)
max-width (frame-pixel-width))))
(when (fboundp 'imagemagick-types)
;; Only do this when ImageMagick is supported.
;; FIXME: When requiring Emacs 27+, remove this (I guess?).
(setf (image-property image :type) 'imagemagick))
(setf (image-property image :max-width) max-width
(image-property image :max-height) max-height
(image-property image :relief) 2
(image-property image :margin) 5
(image-property image :pointer) 'hand)
(concat "\n"
(ement-room-wrap-prefix " "
'display image
'keymap ement-room-image-keymap)))
(error (format "\n [error inserting image: %s]" (error-message-string err))))
;; Image not downloaded: insert URL as button, and download if enabled.
(prog1
(ement-room-wrap-prefix "[image]"
'action #'browse-url
'button t
'button-data url
'category t
'face 'button
'follow-link t
'help-echo url
'keymap button-map
'mouse-face 'highlight)
(when (and ement-room-images url)
;; Images enabled and URL present: download it.
(plz-run
(plz-queue ement-images-queue
'get url :as 'binary
:then (apply-partially #'ement-room--m.image-callback event ement-room)
:noquery t)))))))
(defun ement-room--m.image-callback (event room data)
"Add downloaded image from DATA to EVENT in ROOM.
Then invalidate EVENT's node to show the image."
(pcase-let* (((cl-struct ement-room (local (map buffer))) room))
(setf (map-elt (ement-event-local event) 'image) data)
(when (buffer-live-p buffer)
(with-current-buffer buffer
(if-let (node (ement-room--ewoc-last-matching ement-ewoc
(lambda (node-data)
(eq node-data event))))
(ewoc-invalidate ement-ewoc node)
;; This shouldn't happen, but very rarely, it can. I haven't figured out why
;; yet, so checking whether a node is found rather than blindly calling
;; `ewoc-invalidate' prevents an error from aborting event processing.
(display-warning 'ement-room--m.image-callback
(format "Event %S not found in room %S (a very rare, as-yet unexplained bug, which can be safely ignored; you may disconnect and reconnect if you wish, but it isn't strictly necessary)"
(ement-event-id event)
(ement-room-display-name room))))))))
(defun ement-room--format-m.file (event)
"Return \"m.file\" EVENT formatted as a string."
;; TODO: Insert thumbnail images when enabled.
(pcase-let* (((cl-struct ement-event
(content (map filename
('info (map mimetype size))
('url mxc-url))))
event)
(url (when mxc-url
(ement--mxc-to-url mxc-url ement-session)))
(human-size (file-size-human-readable size))
(string (format "[file: %s (%s) (%s)]" filename mimetype human-size)))
(concat (propertize string
'action #'browse-url
'button t
'button-data url
'category t
'face 'button
'follow-link t
'help-echo url
'keymap button-map
'mouse-face 'highlight)
(propertize " "
'display '(space :relative-height 1.5)))))
(defun ement-room--format-m.video (event)
"Return \"m.video\" EVENT formatted as a string."
;; TODO: Insert thumbnail images when enabled.
(pcase-let* (((cl-struct ement-event
(content (map body
('info (map mimetype size w h))
('url mxc-url))))
event)
(url (when mxc-url
(ement--mxc-to-url mxc-url ement-session)))
(human-size (file-size-human-readable size))
(string (format "[video: %s (%s) (%sx%s) (%s)]" body mimetype w h human-size)))
(concat (propertize string
'action #'browse-url
'button t
'button-data url
'category t
'face 'button
'follow-link t
'help-echo url
'keymap button-map
'mouse-face 'highlight)
(propertize " "
'display '(space :relative-height 1.5)))))
;;;;; Org format sending
;; Some of these declarations may need updating as Org changes.
(defvar org-export-with-toc)
(defvar org-export-with-broken-links)
(defvar org-export-with-section-numbers)
(defvar org-html-inline-images)
(declare-function org-element-property "org-element")
(declare-function org-export-data "ox")
(declare-function org-export-get-caption "ox")
(declare-function org-export-get-ordinal "ox")
(declare-function org-export-get-reference "ox")
(declare-function org-export-read-attribute "ox")
(declare-function org-html--has-caption-p "ox-html")
(declare-function org-html--textarea-block "ox-html")
(declare-function org-html--translate "ox-html")
(declare-function org-html-export-as-html "ox-html")
(declare-function org-html-format-code "ox-html")
(defun ement-room-compose-org ()
"Activate `org-mode' in current compose buffer.
Configures the buffer appropriately so that saving it will export
the Org buffer's contents."
(interactive)
(unless ement-room-compose-buffer
(user-error "This command should be run in a compose buffer. Use `ement-room-compose-message' first"))
;; Calling `org-mode' seems to wipe out local variables.
(let ((room ement-room)
(session ement-session))
(org-mode)
(ement-room-init-compose-buffer room session))
(setq-local ement-room-send-message-filter #'ement-room-send-org-filter))
(defun ement-room-send-org-filter (content room)
"Return event CONTENT for ROOM having processed its Org content.
The CONTENT's body is exported with
`org-html-export-as-html' (with some adjustments for
compatibility), and the result is added to the CONTENT as
\"formatted_body\"."
(require 'ox-html)
;; The CONTENT alist has string keys before being sent.
(pcase-let* ((body (alist-get "body" content nil nil #'equal))
(formatted-body
(save-window-excursion
(with-temp-buffer
(insert (ement--format-body-mentions body room
:template "[[https://matrix.to/#/%s][%s]]"))
(cl-letf (((symbol-function 'org-html-src-block)
(symbol-function 'ement-room--org-html-src-block)))
(let ((org-export-with-toc nil)
(org-export-with-broken-links t)
(org-export-with-section-numbers nil)
(org-html-inline-images nil))
(org-html-export-as-html nil nil nil 'body-only)))
(with-current-buffer "*Org HTML Export*"
(prog1 (string-trim (buffer-string))
(kill-buffer)))))))
(setf (alist-get "formatted_body" content nil nil #'equal) formatted-body
(alist-get "format" content nil nil #'equal) "org.matrix.custom.html")
content))
(defun ement-room--org-html-src-block (src-block _contents info)
"Transcode a SRC-BLOCK element from Org to HTML.
CONTENTS holds the contents of the item. INFO is a plist holding
contextual information.
This is a copy of `org-html-src-block' that uses Riot
Web-compatible HTML output, using HTML like:
<pre><code class=\"language-python\">..."
(if (org-export-read-attribute :attr_html src-block :textarea)
(org-html--textarea-block src-block)
(let ((lang (pcase (org-element-property :language src-block)
;; Riot's syntax coloring doesn't support "elisp", but "lisp" works.
("elisp" "lisp")
(else else)))
(code (org-html-format-code src-block info))
(label (let ((lbl (and (org-element-property :name src-block)
(org-export-get-reference src-block info))))
(if lbl (format " id=\"%s\"" lbl) ""))))
(if (not lang) (format "<pre class=\"example\"%s>\n%s</pre>" label code)
(format "<div class=\"org-src-container\">\n%s%s\n</div>"
;; Build caption.
(let ((caption (org-export-get-caption src-block)))
(if (not caption) ""
(let ((listing-number
(format
"<span class=\"listing-number\">%s </span>"
(format
(org-html--translate "Listing %d:" info)
(org-export-get-ordinal
src-block info nil #'org-html--has-caption-p)))))
(format "<label class=\"org-src-name\">%s%s</label>"
listing-number
(string-trim (org-export-data caption info))))))
;; Contents.
(format "<pre><code class=\"src language-%s\"%s>%s</code></pre>"
lang label code))))))
;;;;; Completion
;; Completing member and room names.
(defun ement-room--complete-members-at-point ()
"Complete member names and IDs at point.
Uses members in the current buffer's room. For use in
`completion-at-point-functions'."
(let ((beg (save-excursion
(when (re-search-backward (rx (or bol bos blank)) nil t)
(if (minibufferp)
(1+ (point))
(point)))))
(end (point))
(collection-fn (completion-table-dynamic
;; The manual seems to show the FUN ignoring any
;; arguments, but the `completion-table-dynamic' docstring
;; seems to say that it should use the argument.
(lambda (_ignore)
(ement-room--member-names-and-ids)))))
(when beg
(list beg end collection-fn :exclusive 'no))))
(defun ement-room--complete-rooms-at-point ()
"Complete room aliases and IDs at point.
For use in `completion-at-point-functions'."
(let ((beg (save-excursion
(when (re-search-backward (rx (or bol bos blank) (or "!" "#")) nil t)
(if (minibufferp)
(1+ (point))
(point)))))
(end (point))
(collection-fn (completion-table-dynamic
;; The manual seems to show the FUN ignoring any
;; arguments, but the `completion-table-dynamic' docstring
;; seems to say that it should use the argument.
(lambda (_ignore)
(ement-room--room-aliases-and-ids)))))
(when beg
(list beg end collection-fn :exclusive 'no))))
;; TODO: Use `cl-pushnew' in these two functions instead of `delete-dups'.
(defun ement-room--member-names-and-ids ()
"Return a list of member names and IDs seen in current room.
If room's `members' table is filled, use it; otherwise, fetch
members list and return already-seen members instead. For use in
`completion-at-point-functions'."
;; For now, we just collect a list of members from events we've seen.
;; TODO: In the future, we may maintain a per-room table of members, which
;; would be more suitable for completing names according to the spec.
(pcase-let* ((room (if (minibufferp)
(buffer-local-value
'ement-room (window-buffer (minibuffer-selected-window)))
ement-room))
(session (if (minibufferp)
(buffer-local-value
'ement-session (window-buffer (minibuffer-selected-window)))
ement-session))
((cl-struct ement-room members) room)
(members (if (alist-get 'fetched-members-p (ement-room-local room))
(hash-table-values members)
;; HACK: Members table empty: update list and use known events
;; for now.
(ement-singly (alist-get 'getting-members-p (ement-room-local room))
(ement--get-joined-members room session
:then (lambda (_) (setf (alist-get 'getting-members-p (ement-room-local room)) nil))
:else (lambda (_) (setf (alist-get 'getting-members-p (ement-room-local room)) nil))))
(mapcar #'ement-event-sender
(ement-room-timeline ement-room)))))
(delete-dups
(cl-loop for member in members
collect (ement-user-id member)
collect (ement--user-displayname-in room member)))))
(defun ement-room--room-aliases-and-ids ()
"Return a list of room names and aliases seen in current session.
For use in `completion-at-point-functions'."
(let* ((session (if (minibufferp)
(buffer-local-value
'ement-session (window-buffer (minibuffer-selected-window)))
ement-session)))
(delete-dups
(delq nil (cl-loop for room in (ement-session-rooms session)
collect (ement-room-id room)
collect (ement-room-canonical-alias room))))))
;;;;; Transient
(require 'transient)
(transient-define-prefix ement-room-transient ()
"Transient for Ement Room buffers."
[:pad-keys t
["Movement"
("TAB" "Next event" ement-room-goto-next)
("<backtab>" "Previous event" ement-room-goto-prev)
("SPC" "Scroll up and mark read" ement-room-scroll-up-mark-read)
("S-SPC" "Scroll down" ement-room-scroll-down-command)
("M-SPC" "Jump to fully-read marker" ement-room-goto-fully-read-marker)]
["Switching"
("M-g M-l" "List rooms" ement-room-list)
("M-g M-r" "Switch to other room" ement-view-room)
("M-g M-m" "Switch to mentions buffer" ement-notify-switch-to-mentions-buffer)
("M-g M-n" "Switch to notifications buffer" ement-notify-switch-to-notifications-buffer)
("q" "Quit window" quit-window)]]
[:pad-keys t
["Messages"
("c" "Composition format" ement-room-set-composition-format
:description (lambda ()
(concat "Composition format: "
(propertize (car (cl-rassoc ement-room-send-message-filter
(list (cons "Plain-text" nil)
(cons "Org-mode" 'ement-room-send-org-filter))
:test #'equal))
'face 'transient-value))))
("RET" "Write message" ement-room-send-message)
("S-RET" "Write reply" ement-room-write-reply)
("M-RET" "Compose message in buffer" ement-room-compose-message)
("<insert>" "Edit message" ement-room-edit-message)
("C-k" "Delete message" ement-room-delete-message)
("s r" "Send reaction" ement-room-send-reaction)
("s e" "Send emote" ement-room-send-emote)
("s f" "Send file" ement-room-send-file)
("s i" "Send image" ement-room-send-image)]
["Users"
("u RET" "Send direct message" ement-send-direct-message)
("u i" "Invite user" ement-invite-user)
("u I" "Ignore user" ement-ignore-user)]]
[:pad-keys t
["Room"
("M-s o" "Occur search in room" ement-room-occur)
("r d" "Describe room" ement-describe-room)
("r m" "List members" ement-list-members)
("r t" "Set topic" ement-room-set-topic)
("r f" "Set message format" ement-room-set-message-format)
("r N" "Override name" ement-room-override-name
:description (lambda ()
(format "Name override: %s"
(if-let* ((event (alist-get "org.matrix.msc3015.m.room.name.override"
(ement-room-account-data ement-room) nil nil #'equal))
(name (map-nested-elt event '(content name))))
(propertize name 'face 'transient-value)
(propertize "none" 'face 'transient-inactive-value)))))
("r n" "Set notification state" ement-room-set-notification-state
:description (lambda ()
(let ((state (ement-room-notification-state ement-room ement-session)))
(format "Notifications (%s|%s|%s|%s|%s)"
(propertize "default"
'face (pcase state
(`nil 'transient-value)
(_ 'transient-inactive-value)))
(propertize "all-loud"
'face (pcase state
('all-loud 'transient-value)
(_ 'transient-inactive-value)))
(propertize "all"
'face (pcase state
('all 'transient-value)
(_ 'transient-inactive-value)))
(propertize "mentions"
'face (pcase state
('mentions-and-keywords 'transient-value)
(_ 'transient-inactive-value)))
(propertize "none"
'face (pcase state
('none 'transient-value)
(_ 'transient-inactive-value)))))))
("r T" "Tag/untag room" ement-tag-room
:description (lambda ()
(format "Tag/untag room (%s|%s)"
(propertize "Fav"
'face (if (ement--room-tagged-p "m.favourite" ement-room)
'transient-value 'transient-inactive-value))
(propertize "Low-prio"
'face (if (ement--room-tagged-p "m.lowpriority" ement-room)
'transient-value 'transient-inactive-value)))))]
["Room membership"
("R c" "Create room" ement-create-room)
("R j" "Join room" ement-join-room)
("R l" "Leave room" ement-leave-room)
("R F" "Forget room" ement-forget-room)
("R n" "Set nick" ement-room-set-display-name
:description (lambda ()
(format "Set nick (%s)"
(propertize (ement--user-displayname-in
ement-room (gethash (ement-user-id (ement-session-user ement-session))
ement-users))
'face 'transient-value))))
("R s" "Toggle spaces" ement-room-toggle-space
:description (lambda ()
(format "Toggle spaces (%s)"
(if-let ((spaces (ement--room-spaces ement-room ement-session)))
(string-join
(mapcar (lambda (space)
(propertize (ement-room-display-name space)
'face 'transient-value))
spaces)
", ")
(propertize "none" 'face 'transient-inactive-value)))))]]
["Other"
("v" "View event" ement-room-view-event)
("g" "Sync new messages" ement-room-sync
:if (lambda ()
(interactive)
(or (not ement-auto-sync)
(not (map-elt ement-syncs ement-session)))))])
;;;; Footer
(provide 'ement-room)
;;; ement-room.el ends here
;;; ement-room-list.el --- List Ement rooms -*- lexical-binding: t; -*-
;; Copyright (C) 2022 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; This library implements a room list view using `taxy' and `taxy-magit-section' for
;; dynamic, programmable grouping.
;;; Code:
(require 'button)
(require 'rx)
(require 'persist)
(require 'svg-lib)
(require 'taxy)
(require 'taxy-magit-section)
(require 'ement-lib)
(defgroup ement-room-list nil
"Group Ement rooms with Taxy."
:group 'ement)
;;;; Mouse commands
;; Since mouse-activated commands must handle mouse events, we define a simple macro to
;; wrap a command into a mouse-event-accepting one.
(defmacro ement-room-list-define-mouse-command (command)
"Define a command that calls COMMAND interactively with point at mouse event.
COMMAND should be a form that evaluates to a function symbol; if
a symbol, it should be unquoted.."
(let ((docstring (format "Call command `%s' interactively with point at EVENT." command))
(name (intern (format "ement-room-list-mouse-%s" command))))
`(defun ,name (event)
,docstring
(interactive "e")
(mouse-set-point event)
(call-interactively #',command))))
;;;; Variables
(declare-function ement-room-toggle-space "ement-room")
(defvar ement-room-list-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'ement-room-list-RET)
(define-key map (kbd "SPC") #'ement-room-list-next-unread)
(define-key map [tab] #'ement-room-list-section-toggle)
(define-key map [mouse-1] (ement-room-list-define-mouse-command ement-room-list-RET))
(define-key map [mouse-2] (ement-room-list-define-mouse-command ement-room-list-kill-buffer))
(define-key map (kbd "k") #'ement-room-list-kill-buffer)
(define-key map (kbd "s") #'ement-room-toggle-space)
map)
"Keymap for `ement-room-list' buffers.
See also `ement-room-list-button-map'.")
(defvar ement-room-list-button-map
;; This map is needed because some columns are propertized as buttons, which override
;; the main keymap.
;; TODO: Is it possible to adjust the button properties to obviate this map?
(let ((map (make-sparse-keymap)))
(define-key map [mouse-1] (ement-room-list-define-mouse-command ement-room-list-RET))
(define-key map [mouse-2] (ement-room-list-define-mouse-command ement-room-list-kill-buffer))
map)
"Keymap for buttonized text in `ement-room-list' buffers.")
(defvar ement-room-list-timestamp-colors nil
"List of colors used for timestamps.
Set automatically when `ement-room-list-mode' is activated.")
(defvar ement-room)
(defvar ement-session)
(defvar ement-sessions)
(defvar ement-room-prism-minimum-contrast)
;;;;; Persistent variables
(persist-defvar ement-room-list-visibility-cache nil
"Applied to `magit-section-visibility-cache', which see.")
;;;; Customization
(defcustom ement-room-list-auto-update t
"Automatically update the taxy-based room list buffer."
:type 'boolean)
(defcustom ement-room-list-avatars (display-images-p)
"Show room avatars in the room list."
:type 'boolean)
;;;;; Faces
(defface ement-room-list-direct
;; In case `font-lock-constant-face' is bold, we set the weight to normal, so it can be
;; made bold for unread rooms only.
'((t (:weight normal :inherit (font-lock-constant-face ement-room-list-name))))
"Direct rooms.")
(defface ement-room-list-favourite '((t (:inherit (font-lock-doc-face ement-room-list-name))))
"Favourite rooms.")
(defface ement-room-list-invited
'((t (:inherit italic ement-room-list-name)))
"Invited rooms.")
(defface ement-room-list-left
'((t (:strike-through t :inherit ement-room-list-name)))
"Left rooms.")
(defface ement-room-list-low-priority '((t (:inherit (font-lock-comment-face ement-room-list-name))))
"Low-priority rooms.")
(defface ement-room-list-name
'((t (:inherit font-lock-function-name-face button)))
"Non-direct rooms.")
(defface ement-room-list-space '((t (:inherit (font-lock-regexp-grouping-backslash ement-room-list-name))))
"Space rooms."
:group 'ement-room-list)
(defface ement-room-list-unread
'((t (:inherit bold ement-room-list-name)))
"Unread rooms.")
(defface ement-room-list-recent '((t (:inherit font-lock-warning-face)))
"Latest timestamp of recently updated rooms.
The foreground color is used to generate a gradient of colors
from recent to non-recent for rooms updated in the past 24
hours but at least one hour ago.")
(defface ement-room-list-very-recent '((t (:inherit error)))
"Latest timestamp of very recently updated rooms.
The foreground color is used to generate a gradient of colors
from recent to non-recent for rooms updated in the past hour.")
;;;; Keys
;; Since some of these keys need access to the session, and room
;; structs don't include the session, we use a two-element vector in
;; which the session is the second element.
(eval-and-compile
(taxy-define-key-definer ement-room-list-define-key
ement-room-list-keys "ement-room-list-key" "FIXME: Docstring."))
(ement-room-list-define-key membership (&key name status)
;; FIXME: Docstring: status should be a symbol of either `invite', `join', `leave'.
(cl-labels ((format-membership (membership)
(pcase membership
('join "Joined")
('invite "Invited")
('leave "[Left]"))))
(pcase-let ((`[,(cl-struct ement-room (status membership)) ,_session] item))
(if status
(when (equal status membership)
(or name (format-membership membership)))
(format-membership membership)))))
(ement-room-list-define-key alias (&key name regexp)
(pcase-let ((`[,(cl-struct ement-room canonical-alias) ,_session] item))
(when canonical-alias
(when (string-match-p regexp canonical-alias)
name))))
(ement-room-list-define-key buffer ()
(pcase-let ((`[,(cl-struct ement-room (local (map buffer))) ,_session] item))
(when buffer
#("Buffers" 0 7 (help-echo "Rooms with open buffers")))))
(ement-room-list-define-key direct ()
(pcase-let ((`[,room ,session] item))
(when (ement--room-direct-p room session)
"Direct")))
(ement-room-list-define-key people ()
(pcase-let ((`[,room ,session] item))
(when (ement--room-direct-p room session)
(propertize "People" 'face 'ement-room-list-direct))))
(ement-room-list-define-key space (&key name id)
(pcase-let* ((`[,room ,session] item)
((cl-struct ement-session rooms) session)
((cl-struct ement-room type (local (map parents))) room))
(cl-labels ((format-space
(id) (let* ((parent-room (cl-find id rooms :key #'ement-room-id :test #'equal))
(space-name (if parent-room
(ement-room-display-name parent-room)
id)))
(concat "Space: " space-name))))
(when-let ((key (if id
;; ID specified.
(cond ((or (member id parents)
(equal id (ement-room-id room)))
;; Room is in specified space.
(or name (format-space id)))
((and (equal type "m.space")
(equal id (ement-room-id room)))
;; Room is a specified space.
(or name (concat "Space: " (ement-room-display-name room)))
))
;; ID not specified.
(pcase (length parents)
(0 nil)
(1
;; TODO: Make the rooms list a hash table to avoid this lookup.
(format-space (car parents)))
(_
;; TODO: How to handle this better? (though it should be very rare)
(string-join (mapcar #'format-space parents) ", "))))))
(propertize key 'face 'ement-room-list-space)))))
(ement-room-list-define-key space-p ()
"Groups rooms that are themselves spaces."
(pcase-let* ((`[,room ,_session] item)
((cl-struct ement-room type) room))
(when (equal "m.space" type)
"Spaces")))
(ement-room-list-define-key name (&key name regexp)
(pcase-let* ((`[,room ,_session] item)
(display-name (ement--room-display-name room)))
(when display-name
(when (string-match-p regexp display-name)
(or name regexp)))))
(ement-room-list-define-key latest (&key name newer-than older-than)
(pcase-let* ((`[,room ,_session] item)
((cl-struct ement-room latest-ts) room)
(age))
(when latest-ts
(setf age (- (time-convert nil 'integer) (/ latest-ts 1000)))
(cond (newer-than
(when (<= age newer-than)
(or name (format "Newer than %s seconds" newer-than))))
(older-than
(when (>= age older-than)
(or name (format "Older than %s seconds" newer-than))))
(t
;; Default to rooms with traffic in the last day.
(if (<= age 86400)
"Last 24 hours"
"Older than 24 hours"))))))
(ement-room-list-define-key freshness
(&key (intervals '((86400 . "Past 24h")
(604800 . "Past week")
(2419200 . "Past month")
(31536000 . "Past year"))))
(pcase-let* ((`[,room ,_session] item)
((cl-struct ement-room latest-ts) room)
(age))
(when latest-ts
(setf age (- (time-convert nil 'integer) (/ latest-ts 1000)))
(or (alist-get age intervals nil nil #'>)
"Older than a year"))))
(ement-room-list-define-key session (&optional user-id)
(pcase-let ((`[,_room ,(cl-struct ement-session
(user (cl-struct ement-user id)))]
item))
(pcase user-id
(`nil id)
(_ (when (equal user-id id)
user-id)))))
(ement-room-list-define-key topic (&key name regexp)
(pcase-let ((`[,(cl-struct ement-room topic) ,_session] item))
(when (and topic (string-match-p regexp topic))
name)))
(ement-room-list-define-key unread ()
(pcase-let ((`[,room ,session] item))
(when (ement--room-unread-p room session)
"Unread")))
(ement-room-list-define-key favourite ()
:then #'identity
(pcase-let ((`[,room ,_session] item))
(when (ement--room-favourite-p room)
(propertize "Favourite" 'face 'ement-room-list-favourite))))
(ement-room-list-define-key low-priority ()
:then #'identity
(pcase-let ((`[,room ,_session] item))
(when (ement--room-low-priority-p room)
"Low-priority")))
(defcustom ement-room-list-default-keys
'(;; First, group all invitations (this group will appear first since the rooms are
;; already sorted first).
((membership :status 'invite))
;; Group all left rooms (this group will appear last, because the rooms are already
;; sorted last).
((membership :status 'leave))
;; Group all favorite rooms, which are already sorted first.
(favourite)
;; Group all low-priority rooms, which are already sorted last, and within that group,
;; group them by their space, if any.
(low-priority space)
;; Group other rooms which are opened in a buffer.
(buffer)
;; Group other rooms which are unread.
(unread)
;; Group other rooms which are in a space by freshness, then by space.
((and :name "Spaced"
:keys ((not space-p)
space))
freshness space)
;; Group spaces themselves by their parent space (since space headers can't also be
;; items, we have to handle them separately; a bit of a hack, but not too bad).
((and :name "Spaces" :keys (space-p))
space)
;; Group rooms which aren't in spaces by their freshness.
((and :name "Unspaced"
:keys ((not space)
(not people)))
freshness)
;; Group direct rooms by freshness.
(people freshness))
"Default keys."
:type 'sexp)
;;;; Columns
(eval-and-compile
(taxy-magit-section-define-column-definer "ement-room-list"))
(ement-room-list-define-column #("🐱" 0 1 (help-echo "Avatar")) (:align 'right)
(pcase-let* ((`[,room ,_session] item)
((cl-struct ement-room avatar display-name
(local (map room-list-avatar)))
room))
(if ement-room-list-avatars
(or room-list-avatar
(let ((new-avatar
(if avatar
;; NOTE: We resize every avatar to be suitable for this buffer, rather than using
;; the one cached in the room's struct. If the buffer's faces change height, this
;; will need refreshing, but it should be worth it to avoid resizing the images on
;; every update.
(propertize " " 'display
(ement--resize-image (get-text-property 0 'display avatar)
nil (frame-char-height)))
;; Room has no avatar: make one.
(let* ((string (or display-name (ement--room-display-name room)))
(ement-room-prism-minimum-contrast 1)
(color (ement--prism-color string :contrast-with "white")))
(when (string-match (rx bos (or "#" "!" "@")) string)
(setf string (substring string 1)))
(propertize " " 'display (svg-lib-tag (substring string 0 1) nil
:background color :foreground "white"
:stroke 0))))))
(setf (alist-get 'room-list-avatar (ement-room-local room)) new-avatar)))
;; Avatars disabled: use a two-space string.
" ")))
(ement-room-list-define-column "Name" (:max-width 25)
(pcase-let* ((`[,room ,session] item)
((cl-struct ement-room type) room)
(display-name (ement--room-display-name room))
(face))
(or (when display-name
;; TODO: Use code from ement-room-list and put in a dedicated function.
(setf face (cl-copy-list '(:inherit (ement-room-list-name))))
;; In concert with the "Unread" column, this is roughly equivalent to the
;; "red/gray/bold/idle" states listed in <https://github.com/matrix-org/matrix-react-sdk/blob/b0af163002e8252d99b6d7075c83aadd91866735/docs/room-list-store.md#list-ordering-algorithm-importance>.
(when (ement--room-unread-p room session)
;; For some reason, `push' doesn't work with `map-elt'...or does it?
(push 'ement-room-list-unread (map-elt face :inherit)))
(when (equal "m.space" type)
(push 'ement-room-list-space (map-elt face :inherit)))
(when (ement--room-direct-p room session)
(push 'ement-room-list-direct (map-elt face :inherit)))
(when (ement--room-favourite-p room)
(push 'ement-room-list-favourite (map-elt face :inherit)))
(when (ement--room-low-priority-p room)
(push 'ement-room-list-low-priority (map-elt face :inherit)))
(pcase (ement-room-status room)
('invite
(push 'ement-room-list-invited (map-elt face :inherit)))
('leave
(push 'ement-room-list-left (map-elt face :inherit))))
(propertize display-name
'face face
'mouse-face 'highlight
'keymap ement-room-list-button-map))
"")))
(ement-room-list-define-column #("Unread" 0 6 (help-echo "Unread events (Notifications:Highlights)")) (:align 'right)
(pcase-let* ((`[,(cl-struct ement-room unread-notifications) ,_session] item)
((map notification_count highlight_count) unread-notifications))
(if (or (not unread-notifications)
(and (equal 0 notification_count)
(equal 0 highlight_count)))
""
(concat (propertize (number-to-string notification_count)
'face (if (zerop highlight_count)
'default
'ement-room-mention))
":"
(propertize (number-to-string highlight_count)
'face 'highlight)))))
(ement-room-list-define-column "Latest" ()
(pcase-let ((`[,(cl-struct ement-room latest-ts) ,_session] item))
(if latest-ts
(let* ((difference-seconds (- (float-time) (/ latest-ts 1000)))
(n (cl-typecase difference-seconds
((number 0 3599) ;; <1 hour: 10-minute periods.
(truncate (/ difference-seconds 600)))
((number 3600 86400) ;; 1 hour to 1 day: 24 1-hour periods.
(+ 6 (truncate (/ difference-seconds 3600))))
(otherwise ;; Difference in weeks.
(min (/ (length ement-room-list-timestamp-colors) 2)
(+ 24 (truncate (/ difference-seconds 86400 7)))))))
(face (list :foreground (elt ement-room-list-timestamp-colors n)))
(formatted-ts (ement--human-format-duration difference-seconds 'abbreviate)))
(string-match (rx (1+ digit) (repeat 1 alpha)) formatted-ts)
(propertize (match-string 0 formatted-ts) 'face face
'help-echo formatted-ts))
"")))
(ement-room-list-define-column "Topic" (:max-width 35)
(pcase-let ((`[,(cl-struct ement-room topic status) ,_session] item))
;; FIXME: Can the status and type unified, or is this inherent to the spec?
(when topic
(setf topic (replace-regexp-in-string "\n" " " topic 'fixedcase 'literal)))
(pcase status
('invite (concat (propertize "[invited]"
'face 'ement-room-list-invited)
" " topic))
('leave (concat (propertize "[left]"
'face 'ement-room-list-left)
" " topic))
(_ (or topic "")))))
(ement-room-list-define-column "Members" (:align 'right)
(pcase-let ((`[,(cl-struct ement-room
(summary (map ('m.joined_member_count member-count))))
,_session]
item))
(if member-count
(number-to-string member-count)
"")))
(ement-room-list-define-column #("Notifications" 0 5 (help-echo "Notification state")) ()
(pcase-let* ((`[,room ,session] item))
(pcase (ement-room-notification-state room session)
('nil "default")
('all-loud "all (loud)")
('all "all")
('mentions-and-keywords "mentions")
('none "none"))))
(ement-room-list-define-column #("B" 0 1 (help-echo "Buffer exists for room")) ()
(pcase-let ((`[,(cl-struct ement-room (local (map buffer))) ,_session] item))
(if buffer
#("B" 0 1 (help-echo "Buffer exists for room"))
" ")))
(ement-room-list-define-column "Session" ()
(pcase-let ((`[,_room ,(cl-struct ement-session (user (cl-struct ement-user id)))] item))
id))
(unless ement-room-list-columns
;; TODO: Automate this or document it
(setq-default ement-room-list-columns
(get 'ement-room-list-columns 'standard-value)))
;;;; Bookmark support
;; Especially useful with Burly: <https://github.com/alphapapa/burly.el>
(require 'bookmark)
(defun ement-room-list-bookmark-make-record ()
"Return a bookmark record for the `ement-room-list' buffer."
(list "*Ement Room List*"
(cons 'handler #'ement-room-list-bookmark-handler)))
(defun ement-room-list-bookmark-handler (bookmark)
"Show `ement-room-list' room list buffer for BOOKMARK."
(pcase-let* ((`(,_bookmark-name . ,_) bookmark))
(unless ement-sessions
;; MAYBE: Automatically connect.
(user-error "No sessions connected: call `ement-connect' first"))
(ement-room-list)))
;;;; Commands
(defun ement-room-list-section-toggle ()
"Toggle the section at point."
;; HACK: For some reason, when a section's body is hidden, then the buffer is refreshed,
;; and then the section's body is shown again, the body is empty--but then, refreshing
;; the buffer shows its body. So we work around that by refreshing the buffer when a
;; section is toggled. In a way, it makes sense to do this anyway, so the user has the
;; most up-to-date information in the buffer. This hack also works around a minor
;; visual bug that sometimes causes room avatars to be displayed in a section heading
;; when a section is hidden.
(interactive)
(ignore-errors
;; Ignore an error in case point is past the top-level section.
(cl-typecase (aref (oref (magit-current-section) value) 0)
(ement-room
;; HACK: Don't hide rooms themselves (they end up permanently hidden).
nil)
(otherwise
(call-interactively #'magit-section-toggle)
(revert-buffer)))))
;;;###autoload
(defun ement-room-list--after-initial-sync (&rest _ignore)
"Call `ement-room-list', ignoring arguments.
To be called from `ement-after-initial-sync-hook'."
(ement-room-list))
;;;###autoload
(defalias 'ement-list-rooms 'ement-room-list)
;;;###autoload
(cl-defun ement-room-list (&key (buffer-name "*Ement Room List*")
(keys ement-room-list-default-keys)
(display-buffer-action '((display-buffer-reuse-window display-buffer-same-window)))
;; visibility-fn
)
"Show a buffer listing Ement rooms, grouped with Taxy KEYS.
After showing it, its window is selected. The buffer is named
BUFFER-NAME and is shown with DISPLAY-BUFFER-ACTION; or if
DISPLAY-BUFFER-ACTION is nil, the buffer is not displayed."
(interactive)
(let ((inhibit-read-only t)
pos format-table column-sizes window-start room-session-vectors)
(cl-labels (;; (heading-face
;; (depth) (list :inherit (list 'bufler-group (bufler-level-face depth))))
(format-item (item) (gethash item format-table))
;; NOTE: Since these functions take an "item" (which is a [room session]
;; vector), they're prefixed "item-" rather than "room-".
(item-latest-ts
(item) (or (ement-room-latest-ts (elt item 0))
;; Room has no latest timestamp. FIXME: This shouldn't
;; happen, but it can, maybe due to oversights elsewhere.
0))
(item-unread-p
(item) (pcase-let ((`[,room ,session] item))
(ement--room-unread-p room session)))
(item-left-p
(item) (pcase-let ((`[,(cl-struct ement-room status) ,_session] item))
(equal 'leave status)))
(item-buffer-p
(item) (pcase-let ((`[,(cl-struct ement-room (local (map buffer))) ,_session] item))
(buffer-live-p buffer)))
(taxy-unread-p
(taxy) (or (cl-some #'item-unread-p (taxy-items taxy))
(cl-some #'taxy-unread-p (taxy-taxys taxy))))
(item-space-p
(item) (pcase-let ((`[,(cl-struct ement-room type) ,_session] item))
(equal "m.space" type)))
(item-favourite-p
(item) (pcase-let ((`[,room ,_session] item))
(ement--room-favourite-p room)))
(item-low-priority-p
(item) (pcase-let ((`[,room ,_session] item))
(ement--room-low-priority-p room)))
(visible-p
;; This is very confusing and doesn't currently work.
(section) (let ((value (oref section value)))
(if (cl-typecase value
(taxy-magit-section (item-unread-p value))
(ement-room nil))
'show
'hide)))
(item-invited-p
(item) (pcase-let ((`[,(cl-struct ement-room status) ,_session] item))
(equal 'invite status)))
(taxy-latest-ts
(taxy) (apply #'max most-negative-fixnum
(delq nil
(list
(when (taxy-items taxy)
(item-latest-ts (car (taxy-items taxy))))
(when (taxy-taxys taxy)
(cl-loop for sub-taxy in (taxy-taxys taxy)
maximizing (taxy-latest-ts sub-taxy)))))))
(t<nil (a b) (and a (not b)))
(t>nil (a b) (and (not a) b))
(make-fn (&rest args)
(apply #'make-taxy-magit-section
:make #'make-fn
:format-fn #'format-item
:level-indent ement-room-list-level-indent
;; :visibility-fn #'visible-p
;; :heading-indent 2
:item-indent 2
;; :heading-face-fn #'heading-face
args)))
;; (when (get-buffer buffer-name)
;; (kill-buffer buffer-name))
(unless ement-sessions
(error "Ement: Not connected. Use `ement-connect' to connect"))
(setf room-session-vectors
(cl-loop for (_id . session) in ement-sessions
append (cl-loop for room in (ement-session-rooms session)
collect (vector room session))))
(with-current-buffer (get-buffer-create buffer-name)
(setf pos (point))
(ement-room-list-mode)
(delete-all-overlays)
(erase-buffer)
(if (not room-session-vectors)
(insert "No joined rooms. Use command `ement-join-room' to join a room, or `ement-directory' or `ement-directory-search' to find rooms.")
(let* ((taxy (cl-macrolet ((first-item
(pred) `(lambda (taxy)
(when (taxy-items taxy)
(,pred (car (taxy-items taxy))))))
(name= (name) `(lambda (taxy)
(equal ,name (taxy-name taxy)))))
(thread-last
(make-fn
:name "Ement Rooms"
:take (taxy-make-take-function keys ement-room-list-keys))
(taxy-fill room-session-vectors)
(taxy-sort #'> #'item-latest-ts)
(taxy-sort #'t<nil #'item-invited-p)
(taxy-sort #'t<nil #'item-favourite-p)
(taxy-sort #'t>nil #'item-low-priority-p)
(taxy-sort #'t<nil #'item-unread-p)
(taxy-sort #'t<nil #'item-space-p)
;; Within each taxy, left rooms should be sorted last so that one
;; can never be the first room in the taxy (unless it's the taxy
;; of left rooms), which would cause the taxy to be incorrectly
;; sorted last.
(taxy-sort #'t>nil #'item-left-p)
(taxy-sort* #'string< #'taxy-name)
(taxy-sort* #'> #'taxy-latest-ts)
(taxy-sort* #'t<nil (name= "Buffers"))
(taxy-sort* #'t<nil (first-item item-unread-p))
(taxy-sort* #'t<nil (first-item item-favourite-p))
(taxy-sort* #'t<nil (first-item item-invited-p))
(taxy-sort* #'t>nil (first-item item-space-p))
(taxy-sort* #'t>nil (name= "Low-priority"))
(taxy-sort* #'t>nil (first-item item-left-p)))))
(taxy-magit-section-insert-indent-items nil)
(format-cons (taxy-magit-section-format-items
ement-room-list-columns ement-room-list-column-formatters taxy))
(section-ident (when (magit-current-section)
(magit-section-ident (magit-current-section)))))
(setf format-table (car format-cons)
column-sizes (cdr format-cons)
header-line-format (taxy-magit-section-format-header
column-sizes ement-room-list-column-formatters)
window-start (if (get-buffer-window buffer-name)
(window-start (get-buffer-window buffer-name))
0))
(when ement-room-list-visibility-cache
(setf magit-section-visibility-cache ement-room-list-visibility-cache))
(add-hook 'kill-buffer-hook #'ement-room-list--cache-visibility nil 'local)
(save-excursion
(taxy-magit-section-insert taxy :items 'first
;; :blank-between-depth bufler-taxy-blank-between-depth
:initial-depth 0))
(goto-char pos)
(when (and section-ident (magit-get-section section-ident))
(goto-char (oref (magit-get-section section-ident) start))))))
(when display-buffer-action
(when-let ((window (display-buffer buffer-name display-buffer-action)))
(select-window window)))
(when (get-buffer-window buffer-name)
(set-window-start (get-buffer-window buffer-name) window-start))
;; NOTE: In order for `bookmark--jump-via' to work properly, the restored buffer
;; must be set as the current buffer, so we have to do this explicitly here.
(set-buffer buffer-name))))
(cl-defun ement-room-list-side-window (&key (side 'left))
"Show room list in side window on SIDE.
Interactively, with prefix, show on right side; otherwise, on
left."
(interactive (when current-prefix-arg
(list :side 'right)))
(let ((display-buffer-mark-dedicated t))
;; Not sure if binding `display-buffer-mark-dedicated' is still necessary.
(ement-room-list
:display-buffer-action `(display-buffer-in-side-window
(dedicated . t)
(side . ,side)
(window-parameters
(no-delete-other-windows . t))))))
(defun ement-room-list-revert (&optional _ignore-auto _noconfirm)
"Revert current Ement-Room-List buffer."
(interactive)
(with-current-buffer "*Ement Room List*"
;; FIXME: This caching of the visibility only supports the main buffer with the
;; default name, not any special ones with different names.
(setf ement-room-list-visibility-cache magit-section-visibility-cache))
(ement-room-list :display-buffer-action nil))
(defun ement-room-list-kill-buffer (room)
"Kill ROOM's buffer."
(interactive
(ement-with-room-and-session
(ignore ement-session)
(list ement-room)))
(pcase-let (((cl-struct ement-room (local (map buffer))) room)
(kill-buffer-query-functions))
(when (buffer-live-p buffer)
(kill-buffer buffer)
(ement-room-list-revert))))
(declare-function ement-view-room "ement-room")
(defun ement-room-list-RET ()
"View room at point, or cycle section at point."
(interactive)
(cl-etypecase (oref (magit-current-section) value)
(vector (pcase-let ((`[,room ,session] (oref (magit-current-section) value)))
(ement-view-room room session)))
(taxy-magit-section (call-interactively #'ement-room-list-section-toggle))
(null nil)))
(declare-function ement-room-goto-fully-read-marker "ement-room")
(defun ement-room-list-next-unread ()
"Show next unread room."
(interactive)
(unless (button-at (point))
(call-interactively #'forward-button))
(unless (cl-loop with starting-line = (line-number-at-pos)
for value = (oref (magit-current-section) value)
for room = (elt value 0)
for session = (elt value 1)
if (ement--room-unread-p room session)
do (progn
(goto-char (button-end (button-at (point))))
(push-button (1- (point)))
(ement-room-goto-fully-read-marker)
(cl-return t))
else do (call-interactively #'forward-button)
while (> (line-number-at-pos) starting-line))
;; No more unread rooms.
(message "No more unread rooms")))
(define-derived-mode ement-room-list-mode magit-section-mode "Ement-Room-List"
:global nil
(setq-local bookmark-make-record-function #'ement-room-list-bookmark-make-record
revert-buffer-function #'ement-room-list-revert
ement-room-list-timestamp-colors (ement-room-list--timestamp-colors)))
;;;; Functions
(defun ement-room-list--cache-visibility ()
"Save visibility cache.
Sets `ement-room-list-visibility-cache' to the value of
`magit-section-visibility-cache'. To be called in
`kill-buffer-hook'."
(ignore-errors
(when magit-section-visibility-cache
(setf ement-room-list-visibility-cache magit-section-visibility-cache))))
;;;###autoload
(defun ement-room-list-auto-update (_session)
"Automatically update the Taxy room list buffer.
+Does so when variable `ement-room-list-auto-update' is non-nil.
+To be called in `ement-sync-callback-hook'."
(when (and ement-room-list-auto-update
(buffer-live-p (get-buffer "*Ement Room List*")))
(with-current-buffer (get-buffer "*Ement Room List*")
(unless (region-active-p)
;; Don't refresh the list if the region is active (e.g. if the user is trying to
;; operate on multiple rooms).
(revert-buffer)))))
(defun ement-room-list--timestamp-colors ()
"Return a vector of generated latest-timestamp colors for rooms.
Used in `ement-tabulated-room-list' and `ement-room-list'."
(if (or (equal "unspecified-fg" (face-foreground 'default nil 'default))
(equal "unspecified-bg" (face-background 'default nil 'default)))
;; NOTE: On a TTY, the default face's foreground and background colors may be the
;; special values "unspecified-fg"/"unspecified-bg", in which case we can't generate
;; gradients, so we just return a vector of "unspecified-fg". See
;; <https://debbugs.gnu.org/cgi/bugreport.cgi?bug=55623>.
(make-vector 134 "unspecified-fg")
(cl-coerce
(append (mapcar
;; One face per 10-minute period, from "recent" to 1-hour.
(lambda (rgb)
(pcase-let ((`(,r ,g ,b) rgb))
(color-rgb-to-hex r g b 2)))
(color-gradient (color-name-to-rgb (face-foreground 'ement-room-list-very-recent
nil 'default))
(color-name-to-rgb (face-foreground 'ement-room-list-recent
nil 'default))
6))
(mapcar
;; One face per hour, from "recent" to default.
(lambda (rgb)
(pcase-let ((`(,r ,g ,b) rgb))
(color-rgb-to-hex r g b 2)))
(color-gradient (color-name-to-rgb (face-foreground 'ement-room-list-recent
nil 'default))
(color-name-to-rgb (face-foreground 'default nil 'default))
24))
(mapcar
;; One face per week for the last year (actually we
;; generate colors for the past two years' worth so
;; that the face for one-year-ago is halfway to
;; invisible, and we don't use colors past that point).
(lambda (rgb)
(pcase-let ((`(,r ,g ,b) rgb))
(color-rgb-to-hex r g b 2)))
(color-gradient (color-name-to-rgb (face-foreground 'default nil 'default))
(color-name-to-rgb (face-background 'default nil 'default))
104)))
'vector)))
;;;; Footer
(provide 'ement-room-list)
;;; ement-room-list.el ends here
;; Generated package description from ement.el -*- no-byte-compile: t -*-
(define-package "ement" "0.9.2" "Matrix client" '((emacs "27.1") (map "2.1") (persist "0.5") (plz "0.2") (taxy "0.10") (taxy-magit-section "0.12.1") (svg-lib "0.2.5") (transient "0.3.7")) :commit "7f39fa5694232fa3f0a32b2104187fe1e886c202" :authors '(("Adam Porter" . "adam@alphapapa.net")) :maintainer '("Adam Porter" . "adam@alphapapa.net") :keywords '("comm") :url "https://github.com/alphapapa/ement.el")
;;; ement-notify.el --- Notifications for Ement events -*- lexical-binding: t; -*-
;; Copyright (C) 2022 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; This library implements notifications for Ement events.
;;; Code:
;;;; Requirements
(require 'cl-lib)
(require 'map)
(require 'notifications)
(require 'ement-lib)
(require 'ement-room)
(eval-when-compile
(require 'ement-structs))
;;;; Variables
(declare-function ement-room-list "ement-room-list")
(defvar ement-notify-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "S-<return>") #'ement-notify-reply)
(define-key map (kbd "M-g M-l") #'ement-room-list)
(define-key map (kbd "M-g M-m") #'ement-notify-switch-to-mentions-buffer)
(define-key map (kbd "M-g M-n") #'ement-notify-switch-to-notifications-buffer)
(make-composed-keymap (list map button-buffer-map) 'view-mode-map))
"Map for Ement notification buffers.")
(defvar ement-notify-dbus-p
(and (featurep 'dbusbind)
(require 'dbus nil :no-error)
(dbus-ignore-errors (dbus-get-unique-name :session))
;; By default, emacs waits up to 25 seconds for a PONG. Realistically, if there's
;; no pong after 2000ms, there's pretty sure no notification service connected or
;; the system's setup has issues.
(dbus-ping :session "org.freedesktop.Notifications" 2000))
"Whether D-Bus notifications are usable.")
;;;; Customization
(defgroup ement-notify nil
"Notification options."
:group 'ement)
(defcustom ement-notify-ignore-predicates
'(ement-notify--event-not-message-p ement-notify--event-from-session-user-p)
"Display notification if none of these return non-nil for an event.
Each predicate is called with three arguments: the event, the
room, and the session (each the respective struct)."
:type '(repeat (choice (function-item ement-notify--event-not-message-p)
(function-item ement-notify--event-from-session-user-p)
(function :tag "Custom predicate"))))
(defcustom ement-notify-log-predicates
'(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p
ement-notify--room-buffer-live-p
ement-notify--room-unread-p)
"Predicates to determine whether to log an event to the notifications buffer.
If one of these returns non-nil for an event, the event is logged."
:type 'hook
:options '(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p
ement-notify--room-buffer-live-p
ement-notify--room-unread-p))
(defcustom ement-notify-mark-frame-urgent-predicates
'(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p)
"Predicates to determine whether to mark a frame as urgent.
If one of these returns non-nil for an event, the frame that most
recently showed the event's room's buffer is marked
urgent. (Only works on X, not other GUI platforms.)"
:type 'hook
:options '(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p))
(defcustom ement-notify-mention-predicates
'(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p)
"Predicates to determine whether to log an event to the mentions buffer.
If one of these returns non-nil for an event, the event is logged."
:type 'hook
:options '(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p))
(defcustom ement-notify-notification-predicates
'(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p
ement-notify--room-buffer-live-p
ement-notify--room-unread-p)
"Predicates to determine whether to send a desktop notification.
If one of these returns non-nil for an event, the notification is sent."
:type 'hook
:options '(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p
ement-notify--room-buffer-live-p
ement-notify--room-unread-p))
(defcustom ement-notify-sound nil
"Sound to play for notifications."
:type '(choice (file :tag "Sound file")
(string :tag "XDG sound name")
(const :tag "Default XDG message sound" "message-new-instant")
(const :tag "Don't play a sound" nil)))
(defcustom ement-notify-limit-room-name-width nil
"Limit the width of room display names in mentions and notifications buffers.
This prevents the margin from being made excessively wide."
:type '(choice (integer :tag "Maximum width")
(const :tag "Unlimited width" nil)))
(defcustom ement-notify-prism-background nil
"Add distinct background color by room to messages in notification buffers.
The color is specific to each room, generated automatically, and
can help distinguish messages by room."
:type 'boolean)
(defcustom ement-notify-room-avatars t
"Show room avatars in the notifications buffers.
This shows room avatars at the left of the window margin in
notification buffers. It's not customizeable beyond that due to
limitations and complexities of displaying strings and images in
margins in Emacs. But it's useful, anyway."
:type 'boolean)
;;;; Commands
(declare-function ement-room-goto-event "ement-room")
(defun ement-notify-button-action (button)
"Show BUTTON's event in its room buffer."
;; TODO: Is `interactive' necessary here?
(interactive)
(let* ((session (button-get button 'session))
(room (button-get button 'room))
(event (button-get button 'event)))
(ement-view-room room session)
(ement-room-goto-event event)))
(defun ement-notify-reply ()
"Send a reply to event at point."
(interactive)
(save-window-excursion
;; Not sure why `call-interactively' doesn't work for `push-button' but oh well.
(push-button)
(call-interactively #'ement-room-write-reply)))
(defun ement-notify-switch-to-notifications-buffer ()
"Switch to \"*Ement Notifications*\" buffer."
(interactive)
(switch-to-buffer (ement-notify--log-buffer "*Ement Notifications*")))
(defun ement-notify-switch-to-mentions-buffer ()
"Switch to \"*Ement Mentions*\" buffer."
(interactive)
(switch-to-buffer (ement-notify--log-buffer "*Ement Mentions*")))
;;;; Functions
(defun ement-notify (event room session)
"Send notifications for EVENT in ROOM on SESSION.
Sends if all of `ement-notify-ignore-predicates' return nil.
Does not do anything if session hasn't finished initial sync."
(when (and (ement-session-has-synced-p session)
(cl-loop for pred in ement-notify-ignore-predicates
never (funcall pred event room session)))
(when (and ement-notify-dbus-p
(run-hook-with-args-until-success 'ement-notify-notification-predicates event room session))
(ement-notify--notifications-notify event room session))
(when (run-hook-with-args-until-success 'ement-notify-log-predicates event room session)
(ement-notify--log-to-buffer event room session))
(when (run-hook-with-args-until-success 'ement-notify-mention-predicates event room session)
(ement-notify--log-to-buffer event room session :buffer-name "*Ement Mentions*"))
(when (run-hook-with-args-until-success 'ement-notify-mark-frame-urgent-predicates event room session)
(ement-notify--mark-frame-urgent event room session))))
(defun ement-notify--mark-frame-urgent (_event room _session)
"Mark frame showing ROOM's buffer as urgent.
If ROOM has no existing buffer, do nothing."
(cl-labels ((mark-frame-urgent
(frame) (let* ((prop "WM_HINTS")
(hints (cl-coerce
(x-window-property prop frame prop nil nil t)
'list)))
(setf (car hints) (logior (car hints) 256))
(x-change-window-property prop hints nil prop 32 t))))
(when-let* ((buffer (alist-get 'buffer (ement-room-local room)))
(frames (cl-loop for frame in (frame-list)
when (eq 'x (framep frame))
collect frame))
(frame (pcase (length frames)
(1 (car frames))
(_
;; Use the frame that most recently showed ROOM's buffer.
(car (sort frames
(lambda (frame-a frame-b)
(let ((a-pos (cl-position buffer (buffer-list frame-a)))
(b-pos (cl-position buffer (buffer-list frame-b))))
(cond ((and a-pos b-pos)
(< a-pos b-pos))
(a-pos)
(b-pos))))))))))
(mark-frame-urgent frame))))
(defun ement-notify--notifications-notify (event room _session)
"Call `notifications-notify' for EVENT in ROOM on SESSION."
(pcase-let* (((cl-struct ement-event sender content) event)
((cl-struct ement-room avatar (display-name room-displayname)) room)
((map body) content)
(room-name (or room-displayname (ement--room-display-name room)))
(sender-name (ement--user-displayname-in room sender))
(title (format "%s in %s" sender-name room-name)))
;; TODO: Encode HTML entities.
(when (stringp body)
;; If event has no body, it was probably redacted or something, so don't notify.
(truncate-string-to-width body 60)
(notifications-notify :title title :body body
:app-name "Ement.el"
:app-icon (when avatar
(ement-notify--temp-file
(plist-get (cdr (get-text-property 0 'display avatar)) :data)))
:category "im.received"
:timeout 5000
;; FIXME: Using :sound-file seems to do nothing, ever. Maybe a bug in notifications-notify?
:sound-file (when (and ement-notify-sound
(file-name-absolute-p ement-notify-sound))
ement-notify-sound)
:sound-name (when (and ement-notify-sound
(not (file-name-absolute-p ement-notify-sound)))
ement-notify-sound)
;; TODO: Show when action used.
;; :actions '("default" "Show")
;; :on-action #'ement-notify-show
))))
(cl-defun ement-notify--temp-file (content &key (timeout 5))
"Return a filename holding CONTENT, and delete it after TIMEOUT seconds."
(let ((filename (make-temp-file "ement-notify--temp-file-"))
(coding-system-for-write 'no-conversion))
(with-temp-file filename
(insert content))
(run-at-time timeout nil (lambda ()
(delete-file filename)))
filename))
(define-derived-mode ement-notify-mode ement-room-mode "Ement Notify"
(setf ement-room-sender-in-left-margin nil
left-margin-width 0
right-margin-width 8)
(setq-local ement-room-message-format-spec "[%o%O] %S> %B%R%t"
bookmark-make-record-function #'ement-notify-bookmark-make-record))
(cl-defun ement-notify--log-to-buffer (event room session &key (buffer-name "*Ement Notifications*"))
"Log EVENT in ROOM on SESSION to \"*Ement Notifications*\" buffer."
(with-demoted-errors "ement-notify--log-to-buffer: %S"
;; HACK: We only log "m.room.message" events for now. This shouldn't be necessary
;; since we have `ement-notify--event-message-p' in `ement-notify-predicates', but
;; just to be safe...
(when (equal "m.room.message" (ement-event-type event))
(with-current-buffer (ement-notify--log-buffer buffer-name)
(let* ((ement-session session)
(ement-room room)
(ement-room-sender-in-left-margin nil)
(ement-room-message-format-spec "%o%O »%W %S> %B%R%t")
(new-node (ement-room--insert-event event))
(inhibit-read-only t)
start end)
(ewoc-goto-node ement-ewoc new-node)
(setf start (point))
(if-let (next-node (ewoc-next ement-ewoc new-node))
(ewoc-goto-node ement-ewoc next-node)
(goto-char (point-max)))
(setf end (- (point) 2))
(add-text-properties start end
(list 'button '(t)
'category 'default-button
'action #'ement-notify-button-action
'session session
'room room
'event event))
;; Remove button face property.
(alter-text-property start end 'face
(lambda (face)
(pcase face
('button nil)
((pred listp) (remq 'button face))
(_ face))))
(when ement-notify-prism-background
(add-face-text-property start end (list :background (ement-notify--room-background-color room)
:extend t))))))))
(defun ement-notify--log-buffer (name)
"Return an Ement notifications buffer named NAME."
(or (get-buffer name)
(with-current-buffer (get-buffer-create name)
(ement-notify-mode)
(current-buffer))))
(defun ement-notify--room-background-color (room)
"Return a background color on which to display ROOM's messages."
(or (alist-get 'notify-background-color (ement-room-local room))
(setf (alist-get 'notify-background-color (ement-room-local room))
(let ((color (color-desaturate-name
(ement--prism-color (ement-room-id room) :contrast-with (face-foreground 'default))
50)))
(if (ement--color-dark-p (color-name-to-rgb (face-background 'default)))
(color-darken-name color 25)
(color-lighten-name color 25))))))
;;;;; Predicates
(defun ement-notify--event-mentions-session-user-p (event room session)
"Return non-nil if EVENT in ROOM mentions SESSION's user.
If EVENT's sender is SESSION's user, returns nil."
(pcase-let* (((cl-struct ement-session user) session)
((cl-struct ement-event sender) event))
(unless (equal (ement-user-id user) (ement-user-id sender))
(ement-room--event-mentions-user-p event user room))))
(defun ement-notify--room-buffer-live-p (_event room _session)
"Return non-nil if ROOM has a live buffer."
(buffer-live-p (alist-get 'buffer (ement-room-local room))))
(defun ement-notify--room-unread-p (_event room _session)
"Return non-nil if ROOM has unread notifications.
According to the room's notification configuration on the server."
(pcase-let* (((cl-struct ement-room unread-notifications) room)
((map notification_count highlight_count) unread-notifications))
(not (and (equal 0 notification_count)
(equal 0 highlight_count)))))
(defun ement-notify--event-message-p (event _room _session)
"Return non-nil if EVENT is an \"m.room.message\" event."
(equal "m.room.message" (ement-event-type event)))
(defun ement-notify--event-not-message-p (event _room _session)
"Return non-nil if EVENT is not an \"m.room.message\" event."
(not (equal "m.room.message" (ement-event-type event))))
(defun ement-notify--event-from-session-user-p (event _room session)
"Return non-nil if EVENT is sent by SESSION's user."
(equal (ement-user-id (ement-session-user session))
(ement-user-id (ement-event-sender event))))
(defalias 'ement-notify--event-mentions-room-p #'ement--event-mentions-room-p)
;;;; Bookmark support
;; Especially useful with Burly: <https://github.com/alphapapa/burly.el>
(require 'bookmark)
(defun ement-notify-bookmark-make-record ()
"Return a bookmark record for the current `ement-notify' buffer."
(list (buffer-name)
;; It seems silly to have to record the buffer name twice, but the
;; `bookmark-make-record' function seems to override the bookmark name sometimes,
;; which makes the result useless unless we save the buffer name separately.
(cons 'buffer-name (buffer-name))
(cons 'handler #'ement-notify-bookmark-handler)))
(defun ement-notify-bookmark-handler (bookmark)
"Show Ement notifications buffer for BOOKMARK."
(pcase-let ((`(,_bookmark-name . ,(map buffer-name)) bookmark))
(switch-to-buffer (ement-notify--log-buffer buffer-name))))
;;;; Footer
(provide 'ement-notify)
;;; ement-notify.el ends here
;;; ement-macros.el --- Ement macros -*- lexical-binding: t; -*-
;; Copyright (C) 2022 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;;
;;; Code:
;;;; Requirements
(require 'map)
;;;; Debugging
(require 'warnings)
;; NOTE: Uncomment this form and `emacs-lisp-byte-compile-and-load' the file to enable
;; `ement-debug' messages. This is commented out by default because, even though the
;; messages are only displayed when `warning-minimum-log-level' is `:debug' at runtime, if
;; that is so at expansion time, the expanded macro calls format the message and check the
;; log level at runtime, which is not zero-cost.
;; (eval-and-compile
;; (setq-local warning-minimum-log-level nil)
;; (setq-local warning-minimum-log-level :debug))
(cl-defmacro ement-debug (&rest args)
"Display a debug warning showing the runtime value of ARGS.
The warning automatically includes the name of the containing
function, and it is only displayed if `warning-minimum-log-level'
is `:debug' at expansion time (otherwise the macro expands to a
call to `ignore' with ARGS and is eliminated by the
byte-compiler). When debugging, the form also returns nil so,
e.g. it may be used in a conditional in place of nil.
Each of ARGS may be a string, which is displayed as-is, or a
symbol, the value of which is displayed prefixed by its name, or
a Lisp form, which is displayed prefixed by its first symbol.
Before the actual ARGS arguments, you can write keyword
arguments, i.e. alternating keywords and values. The following
keywords are supported:
:buffer BUFFER Name of buffer to pass to `display-warning'.
:level LEVEL Level passed to `display-warning', which see.
Default is :debug."
;; TODO: Can we use a compiler macro to handle this more elegantly?
(pcase-let* ((fn-name (when byte-compile-current-buffer
(with-current-buffer byte-compile-current-buffer
;; This is a hack, but a nifty one.
(save-excursion
(beginning-of-defun)
(cl-second (read (current-buffer)))))))
(plist-args (cl-loop while (keywordp (car args))
collect (pop args)
collect (pop args)))
((map (:buffer buffer) (:level level)) plist-args)
(level (or level :debug))
(string (cl-loop for arg in args
concat (pcase arg
((pred stringp) "%S ")
((pred symbolp)
(concat (upcase (symbol-name arg)) ":%S "))
((pred listp)
(concat "(" (upcase (symbol-name (car arg)))
(pcase (length arg)
(1 ")")
(_ "...)"))
":%S "))))))
(if (eq :debug warning-minimum-log-level)
`(let ((fn-name ,(if fn-name
`',fn-name
;; In an interpreted function: use `backtrace-frame' to get the
;; function name (we have to use a little hackery to figure out
;; how far up the frame to look, but this seems to work).
`(cl-loop for frame in (backtrace-frames)
for fn = (cl-second frame)
when (not (or (subrp fn)
(special-form-p fn)
(eq 'backtrace-frames fn)))
return (make-symbol (format "%s [interpreted]" fn))))))
(display-warning fn-name (format ,string ,@args) ,level ,buffer)
nil)
`(ignore ,@args))))
;;;; Macros
(defmacro ement-alist (&rest pairs)
"Expand to an alist of the keys and values in PAIRS."
`(list ,@(cl-loop for (key value) on pairs by #'cddr
collect `(cons ,key ,value))))
;;;;; Anaphoric
;; We could just depend on dash.el and use --first, and anaphora.el (only
;; on MELPA, not ELPA) has aprog1, but in order to reduce dependencies...
(defmacro ement-afirst (form list)
;; Sometimes checkdoc is really annoying. If I use "FORM returns" or
;; "FORM evaluates", it complains, so I can't have a clean linting.
"Return the first element of LIST for which FORM is non-nil.
In FORM, `it' is bound to the element being tested."
(declare (indent 1))
`(cl-loop for it in ,list
;; Avoid the `when' clause's implicit binding of `it'.
do (when ,form
(cl-return it))))
(defmacro ement-aprog1 (first &rest body)
"Like `prog1', but FIRST's value is bound to `it' around BODY."
(declare (indent 1))
`(let ((it ,first))
,@body
it))
(defmacro ement-singly (place-form &rest body)
"If PLACE-FORM is nil, set it non-nil and eval BODY.
BODY should set PLACE-FORM to nil when BODY is eligible to run
again."
(declare (indent defun))
`(unless ,place-form
(setf ,place-form t)
,@body))
;;;;; Progress reporters
;; MAYBE: Submit a `with-progress-reporter' macro to Emacs.
(defalias 'ement-progress-update #'ignore
"By default, this function does nothing. But inside
`ement-with-progress-reporter', it's bound to a function that
updates the current progress reporter.")
(defmacro ement-with-progress-reporter (args &rest body)
"Eval BODY with a progress reporter according to ARGS.
ARGS is a plist of these values:
:when If specified, a form evaluated at runtime to determine
whether to make and update a progress reporter. If not
specified, the reporter is always made and updated.
:reporter A list of arguments passed to
`make-progress-reporter', which see.
Around BODY, the function `ement-progress-update' is set to a
function that calls `progress-reporter-update' on the progress
reporter (or if the :when form evaluates to nil, the function is
set to `ignore'). It optionally takes a VALUE argument, and
without one, it automatically updates the value from the
reporter's min-value to its max-value."
(declare (indent defun))
(pcase-let* ((progress-reporter-sym (gensym))
(progress-value-sym (gensym))
(start-time-sym (gensym))
((map (:when when-form) (:reporter reporter-args)) args)
(`(,_message ,min-value ,_max-value) reporter-args)
(update-fn `(cl-function
(lambda (&optional (value (cl-incf ,progress-value-sym)))
(ement-debug "Updating progress reporter to" value)
(progress-reporter-update ,progress-reporter-sym value)))))
`(let* ((,start-time-sym (current-time))
(,progress-value-sym (or ,min-value 0))
(,progress-reporter-sym ,(if when-form
`(when ,when-form
(make-progress-reporter ,@reporter-args))
`(make-progress-reporter ,@reporter-args))))
;; We use `cl-letf' rather than `cl-labels', because labels expand to lambdas and funcalls,
;; so other functions that call `ement-progress-update' wouldn't call this definition.
(cl-letf (((symbol-function 'ement-progress-update)
,(if when-form
`(if ,when-form
,update-fn
#'ignore)
update-fn)))
,@body
(ement-debug (format "Ement: Progress reporter done (took %.2f seconds)"
(float-time (time-subtract (current-time) ,start-time-sym))))))))
;;;;; Room-related macros
;; Prevent compiler from complaining that `value' is an unknown slot.
(require 'magit-section)
(cl-defmacro ement-with-room-and-session (&rest body)
"Eval BODY with `ement-room' and `ement-session' bound.
If in an `ement-room-list-mode' buffer and `current-prefix-arg'
is nil, use the room and session at point. If in an `ement-room'
buffer and `current-prefix-arg' is nil, use buffer-local value of
`ement-room' and `ement-session'. Otherwise, prompt for them
with `ement-complete-room' or that given with :prompt-form.
BODY may begin with property list arguments, including:
:prompt-form A Lisp form evaluated for the binding of
`ement-room'."
(declare (indent defun))
(pcase-let* ((plist (cl-loop while (keywordp (car body))
append (list (car body) (cadr body))
and do (setf body (cddr body))))
(prompt-form (or (plist-get plist :prompt-form)
'(ement-complete-room :suggest t))))
`(pcase-let* ((`[,list-room ,list-session] (if (eq 'ement-room-list-mode major-mode)
(oref (magit-current-section) value)
[nil nil]))
(ement-room (or list-room ement-room))
(ement-session (or list-session ement-session)))
(when (or current-prefix-arg (not ement-room))
(pcase-let ((`(,room ,session) ,prompt-form))
(setf ement-room room
ement-session session)))
,@body)))
;;;; Variables
;;;; Customization
;;;; Commands
;;;; Functions
;;;; Footer
(provide 'ement-macros)
;;; ement-macros.el ends here
;;; ement-lib.el --- Library of Ement functions -*- lexical-binding: t; -*-
;; Copyright (C) 2022 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; This library provides functions used in other Ement libraries. It exists so they may
;; be required where needed, without causing circular dependencies.
;;; Code:
;;;; Requirements
(eval-when-compile
(require 'eieio)
(require 'ewoc)
(require 'pcase)
(require 'subr-x)
(require 'taxy-magit-section)
(require 'ement-macros))
(require 'cl-lib)
(require 'color)
(require 'map)
(require 'xml)
(require 'ement-api)
(require 'ement-structs)
;;;; Variables
(defvar ement-sessions)
(defvar ement-users)
(defvar ement-ewoc)
(defvar ement-room)
(defvar ement-session)
(defvar ement-room-buffer-name-prefix)
(defvar ement-room-buffer-name-suffix)
(defvar ement-room-leave-kill-buffer)
(defvar ement-room-prism)
(defvar ement-room-prism-color-adjustment)
(defvar ement-room-prism-minimum-contrast)
(defvar ement-room-unread-only-counts-notifications)
;;;; Function declarations
;; Instead of using top-level `declare-function' forms (which can easily become obsolete
;; if not kept with the code that needs them), this allows the use of `(declare (function
;; ...))' forms in each function definition, so that if a function is moved or removed,
;; the `declare-function' goes with it.
;; TODO: Propose this upstream.
(eval-and-compile
(defun ement--byte-run--declare-function (_name _args &rest values)
"Return a `declare-function' form with VALUES.
Allows the use of a form like:
(declare (function FN FILE ...))
inside of a function definition, effectively keeping its
`declare-function' form inside the function definition, ensuring
that stray such forms don't remain if the function is removed."
`(declare-function ,@values))
(cl-pushnew '(function ement--byte-run--declare-function) defun-declarations-alist :test #'equal)
(cl-pushnew '(function ement--byte-run--declare-function) macro-declarations-alist :test #'equal))
;;;; Compatibility
;; These workarounds should be removed when they aren't needed.
;;;;; Emacs 28 color features.
;; Copied from Emacs 28. See <https://github.com/alphapapa/ement.el/issues/99>.
;; FIXME: Remove this workaround when possible.
(eval-and-compile
(unless (boundp 'color-luminance-dark-limit)
(defconst ement--color-luminance-dark-limit 0.325
"The relative luminance below which a color is considered 'dark'.
A 'dark' color in this sense provides better contrast with white
than with black; see `color-dark-p'.
This value was determined experimentally.")))
(defalias 'ement--color-dark-p
(if (fboundp 'color-dark-p)
'color-dark-p
(lambda (rgb)
"Whether RGB is more readable against white than black.
RGB is a 3-element list (R G B), each component in the range [0,1].
This predicate can be used both for determining a suitable (black or white)
contrast colour with RGB as background and as foreground."
(unless (<= 0 (apply #'min rgb) (apply #'max rgb) 1)
(error "RGB components %S not in [0,1]" rgb))
;; Compute the relative luminance after gamma-correcting (assuming sRGB),
;; and compare to a cut-off value determined experimentally.
;; See https://en.wikipedia.org/wiki/Relative_luminance for details.
(let* ((sr (nth 0 rgb))
(sg (nth 1 rgb))
(sb (nth 2 rgb))
;; Gamma-correct the RGB components to linear values.
;; Use the power 2.2 as an approximation to sRGB gamma;
;; it should be good enough for the purpose of this function.
(r (expt sr 2.2))
(g (expt sg 2.2))
(b (expt sb 2.2))
(y (+ (* r 0.2126) (* g 0.7152) (* b 0.0722))))
(< y ement--color-luminance-dark-limit)))))
;;;; Functions
;;;;; Commands
(cl-defun ement-create-room
(session &key name alias topic invite direct-p creation-content
(then (lambda (data)
(message "Created new room: %s" (alist-get 'room_id data))))
(visibility 'private))
"Create new room on SESSION.
Then call function THEN with response data. Optional string
arguments are NAME, ALIAS, and TOPIC. INVITE may be a list of
user IDs to invite. If DIRECT-P, set the \"is_direct\" flag in
the request. CREATION-CONTENT may be an alist of extra keys to
include with the request (see Matrix spec)."
;; TODO: Document other arguments.
;; SPEC: 10.1.1.
(declare (indent defun))
(interactive (list (ement-complete-session)
:name (read-string "New room name: ")
:alias (read-string "New room alias (e.g. \"foo\" for \"#foo:matrix.org\"): ")
:topic (read-string "New room topic: ")
:visibility (completing-read "New room visibility: " '(private public))))
(cl-labels ((given-p
(var) (and var (not (string-empty-p var)))))
(pcase-let* ((endpoint "createRoom")
(data (ement-aprog1
(ement-alist "visibility" visibility)
(when (given-p alias)
(push (cons "room_alias_name" alias) it))
(when (given-p name)
(push (cons "name" name) it))
(when (given-p topic)
(push (cons "topic" topic) it))
(when invite
(push (cons "invite" invite) it))
(when direct-p
(push (cons "is_direct" t) it))
(when creation-content
(push (cons "creation_content" creation-content) it)))))
(ement-api session endpoint :method 'post :data (json-encode data)
:then then))))
(cl-defun ement-create-space
(session &key name alias topic
(then (lambda (data)
(message "Created new space: %s" (alist-get 'room_id data))))
(visibility 'private))
"Create new space on SESSION.
Then call function THEN with response data. Optional string
arguments are NAME, ALIAS, and TOPIC."
(declare (indent defun))
(interactive (list (ement-complete-session)
:name (read-string "New space name: ")
:alias (read-string "New space alias (e.g. \"foo\" for \"#foo:matrix.org\"): ")
:topic (read-string "New space topic: ")
:visibility (completing-read "New space visibility: " '(private public))))
(ement-create-room session :name name :alias alias :topic topic :visibility visibility
:creation-content (ement-alist "type" "m.space") :then then))
(defun ement-room-leave (room session &optional force-p)
"Leave ROOM on SESSION.
If FORCE-P, leave without prompting. ROOM may be an `ement-room'
struct, or a room ID or alias string."
;; TODO: Rename `room' argument to `room-or-id'.
(interactive
(ement-with-room-and-session
:prompt-form (ement-complete-room :prompt "Leave room: ")
(list ement-room ement-session)))
(cl-etypecase room
(ement-room)
(string (setf room (ement-afirst (or (equal room (ement-room-canonical-alias it))
(equal room (ement-room-id it)))
(ement-session-rooms session)))))
(when (or force-p (yes-or-no-p (format "Leave room %s? " (ement--format-room room))))
(pcase-let* (((cl-struct ement-room id) room)
(endpoint (format "rooms/%s/leave" (url-hexify-string id))))
(ement-api session endpoint :method 'post :data ""
:then (lambda (_data)
(when ement-room-leave-kill-buffer
;; NOTE: This generates a symbol and sets its function value to a lambda
;; which removes the symbol from the hook, removing itself from the hook.
;; TODO: When requiring Emacs 27, use `letrec'.
(let* ((leave-fn-symbol (gensym (format "ement-leave-%s" room)))
(leave-fn (lambda (_session)
(remove-hook 'ement-sync-callback-hook leave-fn-symbol)
;; FIXME: Probably need to unintern the symbol.
(when-let ((buffer (map-elt (ement-room-local room) 'buffer)))
(when (buffer-live-p buffer)
(kill-buffer buffer))))))
(setf (symbol-function leave-fn-symbol) leave-fn)
(add-hook 'ement-sync-callback-hook leave-fn-symbol)))
(ement-message "Left room: %s" (ement--format-room room)))
:else (lambda (plz-error)
(pcase-let* (((cl-struct plz-error response) plz-error)
((cl-struct plz-response status body) response)
((map error) (json-read-from-string body)))
(pcase status
(429 (error "Unable to leave room %s: %s" room error))
(_ (error "Unable to leave room %s: %s %S" room status plz-error)))))))))
(defalias 'ement-leave-room #'ement-room-leave)
(defun ement-forget-room (room session &optional force-p)
"Forget ROOM on SESSION.
If FORCE-P (interactively, with prefix), prompt to leave the room
when necessary, and forget the room without prompting."
(interactive
(ement-with-room-and-session
:prompt-form (ement-complete-room :prompt "Forget room: ")
(list ement-room ement-session current-prefix-arg)))
(pcase-let* (((cl-struct ement-room id display-name status) room)
(endpoint (format "rooms/%s/forget" (url-hexify-string id))))
(pcase status
('join (if (and force-p
(yes-or-no-p (format "Leave and forget room %s? (WARNING: You will not be able to rejoin the room to access its content.) "
(ement--format-room room))))
(progn
;; TODO: Use `letrec'.
(let* ((forget-fn-symbol (gensym (format "ement-forget-%s" room)))
(forget-fn (lambda (_session)
(when (equal 'leave (ement-room-status room))
(remove-hook 'ement-sync-callback-hook forget-fn-symbol)
;; FIXME: Probably need to unintern the symbol.
(ement-forget-room room session 'force)))))
(setf (symbol-function forget-fn-symbol) forget-fn)
(add-hook 'ement-sync-callback-hook forget-fn-symbol))
(ement-leave-room room session 'force))
(user-error "Room %s is joined (must be left before forgetting)"
(ement--format-room room))))
('leave (when (or force-p (yes-or-no-p (format "Forget room \"%s\" (%s)? " display-name id)))
(ement-api session endpoint :method 'post :data ""
:then (lambda (_data)
;; NOTE: The spec does not seem to indicate that the action of forgetting
;; a room is synced to other clients, so it seems that we need to remove
;; the room from the session here.
(setf (ement-session-rooms session)
(cl-remove room (ement-session-rooms session)))
;; TODO: Indicate forgotten in footer in room buffer.
(ement-message "Forgot room: %s." (ement--format-room room)))))))))
(defun ement-ignore-user (user-id session &optional unignore-p)
"Ignore USER-ID on SESSION.
If UNIGNORE-P (interactively, with prefix), un-ignore USER."
(interactive (list (ement-complete-user-id)
(ement-complete-session)
current-prefix-arg))
(pcase-let* (((cl-struct ement-session account-data) session)
;; TODO: Store session account-data events in an alist keyed on type.
((map ('content (map ('ignored_users ignored-users))))
(cl-find "m.ignored_user_list" account-data
:key (lambda (event) (alist-get 'type event)) :test #'equal)))
(if unignore-p
;; Being map keys, the user IDs have been interned by `json-read'.
(setf ignored-users (map-delete ignored-users (intern user-id)))
;; Empty maps are used to list ignored users.
(setf (map-elt ignored-users user-id) nil))
(ement-put-account-data session "m.ignored_user_list" (ement-alist "ignored_users" ignored-users)
:then (lambda (data)
(ement-debug "PUT successful" data)
(message "Ement: User %s %s." user-id (if unignore-p "unignored" "ignored"))))))
(defun ement-invite-user (user-id room session)
"Invite USER-ID to ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room."
;; SPEC: 10.4.2.1.
(interactive
(ement-with-room-and-session
(list (ement-complete-user-id) ement-room ement-session)))
(pcase-let* ((endpoint (format "rooms/%s/invite"
(url-hexify-string (ement-room-id room))))
(data (ement-alist "user_id" user-id) ))
(ement-api session endpoint :method 'post :data (json-encode data)
;; TODO: Handle error codes.
:then (lambda (_data)
(message "User %s invited to room \"%s\" (%s)" user-id
(ement-room-display-name room)
(ement-room-id room))))))
(defun ement-list-members (room session bufferp)
"Show members of ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room. If BUFFERP (interactively, with
prefix), or if there are many members, show in a new buffer;
otherwise show in echo area."
(interactive
(ement-with-room-and-session
(list ement-room ement-session current-prefix-arg)))
(pcase-let* (((cl-struct ement-room members (local (map fetched-members-p))) room)
(list-members
(lambda (&optional _)
(cond ((or bufferp (> (hash-table-count members) 51))
;; Show in buffer.
(let* ((buffer (get-buffer-create (format "*Ement members: %s*" (ement-room-display-name room))))
(members (cl-sort (cl-loop for user being the hash-values of members
for id = (ement-user-id user)
for displayname = (ement--user-displayname-in room user)
collect (cons displayname id))
(lambda (a b) (string-collate-lessp a b nil t)) :key #'car))
(displayname-width (cl-loop for member in members
maximizing (string-width (car member))))
(format-string (format "%%-%ss <%%s>" displayname-width)))
(with-current-buffer buffer
(erase-buffer)
(save-excursion
(dolist (member members)
(insert (format format-string (car member) (cdr member)) "\n"))))
(pop-to-buffer buffer)))
(t
;; Show in echo area.
(message "Members of %s (%s): %s" (ement--room-display-name room)
(hash-table-count members)
(string-join (map-apply (lambda (_id user)
(ement--user-displayname-in room user))
members)
", ")))))))
(if fetched-members-p
(funcall list-members)
(ement--get-joined-members room session
:then list-members))
(message "Listing members of %s..." (ement--format-room room))))
(defun ement-send-direct-message (session user-id message)
"Send a direct MESSAGE to USER-ID on SESSION.
Uses the latest existing direct room with the user, or creates a
new one automatically if necessary."
;; SPEC: 13.23.2.
(interactive
(let* ((session (ement-complete-session))
(user-id (ement-complete-user-id))
(message (read-string "Message: ")))
(list session user-id message)))
(if-let* ((seen-user (gethash user-id ement-users))
(existing-direct-room (ement--direct-room-for-user seen-user session)))
(progn
(ement-send-message existing-direct-room session :body message)
(message "Message sent to %s <%s> in room %S <%s>."
(ement--user-displayname-in existing-direct-room seen-user)
user-id
(ement-room-display-name existing-direct-room) (ement-room-id existing-direct-room)))
;; No existing room for user: make new one.
(message "Creating new room for user %s..." user-id)
(ement-create-room session :direct-p t :invite (list user-id)
:then (lambda (data)
(let* ((room-id (alist-get 'room_id data))
(room (or (cl-find room-id (ement-session-rooms session)
:key #'ement-room-id)
;; New room hasn't synced yet: make a temporary struct.
(make-ement-room :id room-id)))
(direct-rooms-account-data-event-content
;; FIXME: Make account-data a map.
(alist-get 'content (cl-find-if (lambda (event)
(equal "m.direct" (alist-get 'type event)))
(ement-session-account-data session)))))
;; Mark new room as direct: add the room to the account-data event, then
;; put the new account data to the server. (See also:
;; <https://github.com/matrix-org/matrix-react-sdk/blob/919aab053e5b3bdb5a150fd90855ad406c19e4ab/src/Rooms.ts#L91>).
(setf (map-elt direct-rooms-account-data-event-content user-id) (vector room-id))
(ement-put-account-data session "m.direct" direct-rooms-account-data-event-content)
;; Send message to new room.
(ement-send-message room session :body message)
(message "Room \"%s\" created for user %s. Sending message..."
room-id user-id))))))
(defun ement-tag-room (tag room session)
"Toggle TAG for ROOM on SESSION."
(interactive
(ement-with-room-and-session
(let* ((prompt (format "Toggle tag (%s): " (ement--format-room ement-room)))
(default-tags
(ement-alist (propertize "Favourite"
'face (when (ement--room-tagged-p "m.favourite" ement-room)
'transient-value))
"m.favourite"
(propertize "Low-priority"
'face (when (ement--room-tagged-p "m.lowpriority" ement-room)
'transient-value))
"m.lowpriority"))
(input (completing-read prompt default-tags))
(tag (alist-get input default-tags (concat "u." input) nil #'string=)))
(list tag ement-room ement-session))))
(pcase-let* (((cl-struct ement-session user) session)
((cl-struct ement-user (id user-id)) user)
((cl-struct ement-room (id room-id)) room)
(endpoint (format "user/%s/rooms/%s/tags/%s"
(url-hexify-string user-id) (url-hexify-string room-id) (url-hexify-string tag)))
(method (if (ement--room-tagged-p tag room) 'delete 'put)))
;; TODO: "order".
;; FIXME: Removing a tag on a left room doesn't seem to work (e.g. to unfavorite a room after leaving it, but not forgetting it).
(ement-api session endpoint :version "v3" :method method :data (pcase method ('put "{}"))
:then (lambda (_)
(ement-message "%s tag %S on %s"
(pcase method
('delete "Removed")
('put "Added"))
tag (ement--format-room room)) ))))
(defun ement-set-display-name (display-name session)
"Set DISPLAY-NAME for user on SESSION.
Sets global displayname."
(interactive
(let* ((session (ement-complete-session))
(display-name (read-string "Set display-name to: " nil nil
(ement-user-displayname (ement-session-user session)))))
(list display-name session)))
(pcase-let* (((cl-struct ement-session user) session)
((cl-struct ement-user (id user-id)) user)
(endpoint (format "profile/%s/displayname" (url-hexify-string user-id))))
(ement-api session endpoint :method 'put :version "v3"
:data (json-encode (ement-alist "displayname" display-name))
:then (lambda (_data)
(message "Ement: Display name set to %S for <%s>" display-name
(ement-user-id (ement-session-user session)))))))
(defun ement-room-set-display-name (display-name room session)
"Set DISPLAY-NAME for user in ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room. Sets the name only in ROOM, not
globally."
(interactive
(ement-with-room-and-session
(let* ((prompt (format "Set display-name in %S to: "
(ement--format-room ement-room)))
(display-name (read-string prompt nil nil
(ement-user-displayname (ement-session-user ement-session)))))
(list display-name ement-room ement-session))))
;; NOTE: This does not seem to be documented in the spec, so we imitate the
;; "/myroomnick" command in SlashCommands.tsx from matrix-react-sdk.
(pcase-let* (((cl-struct ement-room state) room)
((cl-struct ement-session user) session)
((cl-struct ement-user id) user)
(member-event (cl-find-if (lambda (event)
(and (equal id (ement-event-state-key event))
(equal "m.room.member" (ement-event-type event))
(equal "join" (alist-get 'membership (ement-event-content event)))))
state)))
(cl-assert member-event)
(setf (alist-get 'displayname (ement-event-content member-event)) display-name)
(ement-put-state room "m.room.member" id (ement-event-content member-event) session
:then (lambda (_data)
(message "Ement: Display name set to %S for <%s> in %S" display-name
(ement-user-id (ement-session-user session))
(ement--format-room room))))))
;;;;;; Describe room
(defvar ement-describe-room-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "q") #'quit-window)
map)
"Keymap for `ement-describe-room-mode' buffers.")
(define-derived-mode ement-describe-room-mode read-only-mode
"Ement-Describe-Room" "Major mode for `ement-describe-room' buffers.")
(defun ement-describe-room (room session)
"Describe ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room."
(interactive (ement-with-room-and-session (list ement-room ement-session)))
(cl-labels ((heading (string)
(propertize (or string "") 'face 'font-lock-builtin-face))
(id (string)
(propertize (or string "") 'face 'font-lock-constant-face))
(member<
(a b) (string-collate-lessp (car a) (car b) nil t)))
(pcase-let* (((cl-struct ement-room (id room-id) avatar display-name canonical-alias members timeline status topic
(local (map fetched-members-p)))
room)
((cl-struct ement-session user) session)
((cl-struct ement-user (id user-id)) user)
(inhibit-read-only t))
(if (not fetched-members-p)
;; Members not fetched: fetch them and re-call this command.
(ement--get-joined-members room session
:then (lambda (_) (ement-room-describe room session)))
(with-current-buffer (get-buffer-create (format "*Ement room description: %s*" (or display-name canonical-alias room-id)))
(let ((inhibit-read-only t))
(erase-buffer)
;; We avoid looping twice by doing a bit more work here and
;; returning a cons which we destructure.
(pcase-let* ((`(,member-pairs . ,name-width)
(cl-loop for user being the hash-values of members
for formatted = (ement--format-user user room session)
for id = (format "<%s>" (id (ement-user-id user)))
collect (cons formatted id)
into pairs
maximizing (string-width id) into width
finally return (cons (cl-sort pairs #'member<) width)))
;; We put the MXID first, because users may use Unicode characters
;; in their displayname, which `string-width' does not always
;; return perfect results for, and putting it last prevents
;; alignment problems.
(spec (format "%%-%ss %%s" name-width)))
(save-excursion
(insert "\"" (propertize (or display-name canonical-alias room-id) 'face 'font-lock-doc-face) "\"" " is a room "
(propertize (pcase status
('invite "invited")
('join "joined")
('leave "left")
(_ (symbol-name status)))
'face 'font-lock-comment-face)
" on session <" (id user-id) ">.\n\n"
(heading "Avatar: ") (or avatar "") "\n\n"
(heading "ID: ") "<" (id room-id) ">" "\n"
(heading "Alias: ") "<" (id canonical-alias) ">" "\n\n"
(heading "Topic: ") (propertize (or topic "[none]") 'face 'font-lock-comment-face) "\n\n"
(heading "Retrieved events: ") (number-to-string (length timeline)) "\n"
(heading " spanning: ")
(format-time-string "%Y-%m-%d %H:%M:%S"
(/ (ement-event-origin-server-ts
(car (cl-sort (copy-sequence timeline) #'< :key #'ement-event-origin-server-ts)))
1000))
(heading " to ")
(format-time-string "%Y-%m-%d %H:%M:%S\n\n"
(/ (ement-event-origin-server-ts
(car (cl-sort (copy-sequence timeline) #'> :key #'ement-event-origin-server-ts)))
1000))
(heading "Members") " (" (number-to-string (hash-table-count members)) "):\n")
(pcase-dolist (`(,formatted . ,id) member-pairs)
(insert " " (format spec id formatted) "\n")))))
(unless (eq major-mode 'ement-describe-room-mode)
;; Without this check, activating the mode again causes a "Cyclic keymap
;; inheritance" error.
(ement-describe-room-mode))
(pop-to-buffer (current-buffer)))))))
(defalias 'ement-room-describe #'ement-describe-room)
;;;;;; Push rules
;; NOTE: Although v1.4 of the spec is available and describes setting the push rules using
;; the "v3" API endpoint, the Element client continues to use the "r0" endpoint, which is
;; slightly different. This implementation will follow Element's initially, because the
;; spec is not simple, and imitating Element's requests will make it easier.
(defun ement-room-notification-state (room session)
"Return notification state for ROOM on SESSION.
Returns one of nil (meaning default rules are used), `all-loud',
`all', `mentions-and-keywords', or `none'."
;; Following the implementation of getRoomNotifsState() in RoomNotifs.ts in matrix-react-sdk.
;; TODO: Guest support (in which case the state should be `all').
;; TODO: Store account data as a hash table of event types.
(let ((push-rules (cl-find-if (lambda (alist)
(equal "m.push_rules" (alist-get 'type alist)))
(ement-session-account-data session))))
(cl-labels ((override-mute-rule-for-room-p
;; Following findOverrideMuteRule() in RoomNotifs.ts.
(room) (when-let ((overrides (map-nested-elt push-rules '(content global override))))
(cl-loop for rule in overrides
when (and (alist-get 'enabled rule)
(rule-for-room-p rule room))
return rule)))
(rule-for-room-p
;; Following isRuleForRoom() in RoomNotifs.ts.
(rule room) (and (/= 1 (length (alist-get 'conditions rule)))
(pcase-let* ((condition (elt (alist-get 'conditions rule) 0))
((map kind key pattern) condition))
(and (equal "event_match" kind)
(equal "room_id" key)
(equal (ement-room-id room) pattern)))))
(mute-rule-p
(rule) (and (= 1 (length (alist-get 'actions rule)))
(equal "dont_notify" (elt (alist-get 'actions rule) 0))))
(tweak-rule-p
(type rule) (pcase-let (((map ('actions `[,action ,alist])) rule))
(and (equal "notify" action)
(equal type (alist-get 'set_tweak alist))))))
;; If none of these match, nil is returned, meaning that the default rule is used
;; for the room.
(if (override-mute-rule-for-room-p room)
'none
(when-let ((room-rule (cl-find-if (lambda (rule)
(equal (ement-room-id room) (alist-get 'rule_id rule)))
(map-nested-elt push-rules '(content global room)))))
(cond ((not (alist-get 'enabled room-rule))
;; NOTE: According to comment in getRoomNotifsState(), this assumes that
;; the default is to notify for all messages, which "will be 'wrong' for
;; one to one rooms because they will notify loudly for all messages."
'all)
((mute-rule-p room-rule)
;; According to comment, a room-level mute still allows mentions to
;; notify.
'mentions-and-keywords)
((tweak-rule-p "sound" room-rule) 'all-loud)))))))
(defun ement-room-set-notification-state (state room session)
"Set notification STATE for ROOM on SESSION.
Interactively, with prefix, prompt for room and session,
otherwise use current room. STATE may be nil to set the rules to
default, `all', `mentions-and-keywords', or `none'."
;; This merely attempts to reproduce the behavior of Element's simple notification
;; options. It does not attempt to offer all of the features defined in the spec. And,
;; yes, it is rather awkward, having to sometimes* make multiple requests of different
;; "kinds" to set the rules for a single room, but that is how the API works.
;;
;; * It appears that Element only makes multiple requests of different kinds when
;; strictly necessary, but coding that logic now would seem likely to be a waste of
;; time, given that Element doesn't even use the latest version of the spec yet. So
;; we'll just do the "dumb" thing and always send requests of both "override" and
;; "room" kinds, which appears to Just Work™.
;;
;; TODO: Match rules to these user-friendly notification states for presentation. See
;; <https://github.com/matrix-org/matrix-react-sdk/blob/8c67984f50f985aa481df24778078030efa39001/src/RoomNotifs.ts>.
;; TODO: Support `all-loud' ("all_messages_loud").
(interactive
(ement-with-room-and-session
(let* ((prompt (format "Set notification rules for %s: " (ement--format-room ement-room)))
(available-states (ement-alist "Default" nil
"All messages" 'all
"Mentions and keywords" 'mentions-and-keywords
"None" 'none))
(selected-rule (completing-read prompt (mapcar #'car available-states) nil t))
(state (alist-get selected-rule available-states nil nil #'equal)))
(list state ement-room ement-session))))
(cl-labels ((set-rule (kind rule queue message-fn)
(pcase-let* (((cl-struct ement-room (id room-id)) room)
(rule-id (url-hexify-string room-id))
(endpoint (format "pushrules/global/%s/%s" kind rule-id))
(method (if rule 'put 'delete))
(then (if rule
;; Setting rules requires PUTting the rules, then making a second
;; request to enable them.
(lambda (_data)
(ement-api session (concat endpoint "/enabled") :queue queue :version "r0"
:method 'put :data (json-encode (ement-alist 'enabled t))
:then message-fn))
message-fn)))
(ement-api session endpoint :queue queue :method method :version "r0"
:data (json-encode rule)
:then then
:else (lambda (plz-error)
(pcase-let* (((cl-struct plz-error response) plz-error)
((cl-struct plz-response status) response))
(pcase status
(404 (pcase rule
(`nil
;; Room already had no rules, so none being found is not an
;; error.
nil)
(_ ;; Unexpected error: re-signal.
(ement-api-error plz-error))))
(_ ;; Unexpected error: re-signal.
(ement-api-error plz-error)))))))))
(pcase-let* ((available-states
(ement-alist
nil (ement-alist
"override" nil
"room" nil)
'all (ement-alist
"override" nil
"room" (ement-alist
'actions (vector "notify" (ement-alist
'set_tweak "sound"
'value "default"))))
'mentions-and-keywords (ement-alist
"override" nil
"room" (ement-alist
'actions (vector "dont_notify")))
'none (ement-alist
"override" (ement-alist
'actions (vector "dont_notify")
'conditions (vector (ement-alist
'kind "event_match"
'key "room_id"
'pattern (ement-room-id room))))
"room" nil)))
(kinds-and-rules (alist-get state available-states nil nil #'equal)))
(cl-loop with queue = (make-plz-queue :limit 1)
with total = (1- (length kinds-and-rules))
for count from 0
for message-fn = (if (equal count total)
(lambda (_data)
(message "Set notification rules for room: %s" (ement--format-room room)))
#'ignore)
for (kind . state) in kinds-and-rules
do (set-rule kind state queue message-fn)))))
;;;;; Public functions
;; These functions could reasonably be called by code in other packages.
(cl-defun ement-put-state
(room type key data session
&key (then (lambda (response-data)
(ement-debug "State data put on room" response-data data room session))))
"Put state event of TYPE with KEY and DATA on ROOM on SESSION.
DATA should be an alist, which will become the JSON request
body."
(declare (indent defun))
(pcase-let* ((endpoint (format "rooms/%s/state/%s/%s"
(url-hexify-string (ement-room-id room))
type key)))
(ement-api session endpoint :method 'put :data (json-encode data)
;; TODO: Handle error codes.
:then then)))
(defun ement-message (format-string &rest args)
"Call `message' on FORMAT-STRING prefixed with \"Ement: \"."
;; TODO: Use this function everywhere we use `message'.
(apply #'message (concat "Ement: " format-string) args))
(cl-defun ement-upload (session &key data filename then else
(content-type "application/octet-stream"))
"Upload DATA with FILENAME to content repository on SESSION.
THEN and ELSE are passed to `ement-api', which see."
(declare (indent defun))
(ement-api session "upload" :method 'post :endpoint-category "media"
;; NOTE: Element currently uses "r0" not "v3", so so do we.
:params (when filename
(list (list "filename" filename)))
:content-type content-type :data data :data-type 'binary
:then then :else else))
(cl-defun ement-complete-session (&key (prompt "Session: "))
"Return an Ement session selected with completion."
(cl-etypecase (length ement-sessions)
((integer 1 1) (cdar ement-sessions))
((integer 2 *) (let* ((ids (mapcar #'car ement-sessions))
(selected-id (completing-read prompt ids nil t)))
(alist-get selected-id ement-sessions nil nil #'equal)))
(otherwise (user-error "No active sessions. Call `ement-connect' to log in"))))
(declare-function ewoc-locate "ewoc")
(defun ement-complete-user-id ()
"Return a user-id selected with completion.
Selects from seen users on all sessions. If point is on an
event, suggests the event's sender as initial input. Allows
unseen user IDs to be input as well."
(cl-labels ((format-user (user)
;; FIXME: Per-room displaynames are now stored in room structs
;; rather than user structs, so to be complete, this needs to
;; iterate over all known rooms, looking for the user's
;; displayname in that room.
(format "%s <%s>"
(ement-user-displayname user)
(ement-user-id user))))
(let* ((display-to-id
(cl-loop for key being the hash-keys of ement-users
using (hash-values value)
collect (cons (format-user value) key)))
(user-at-point (when (equal major-mode 'ement-room-mode)
(when-let ((node (ewoc-locate ement-ewoc)))
(when (ement-event-p (ewoc-data node))
(format-user (ement-event-sender (ewoc-data node)))))))
(selected-user (completing-read "User: " (mapcar #'car display-to-id)
nil nil user-at-point)))
(or (alist-get selected-user display-to-id nil nil #'equal)
selected-user))))
(cl-defun ement-put-account-data
(session type data &key room
(then (lambda (received-data)
;; Handle echoed-back account data event (the spec does not explain this,
;; but see <https://github.com/matrix-org/matrix-react-sdk/blob/675b4271e9c6e33be354a93fcd7807253bd27fcd/src/settings/handlers/AccountSettingsHandler.ts#L150>).
;; FIXME: Make session account-data a map instead of a list of events.
(if room
(push received-data (ement-room-account-data room))
(push received-data (ement-session-account-data session)))
;; NOTE: Commenting out this ement-debug form because a bug in Emacs
;; causes this long string to be interpreted as the function's
;; docstring and cause a too-long-docstring warning.
;; (ement-debug "Account data put and received back on session %s: PUT(json-encoded):%S RECEIVED:%S"
;; (ement-user-id (ement-session-user session)) (json-encode data) received-data)
)))
"Put account data of TYPE with DATA on SESSION.
If ROOM, put it on that room's account data. Also handle the
echoed-back event."
(declare (indent defun))
(pcase-let* (((cl-struct ement-session (user (cl-struct ement-user (id user-id)))) session)
(room-part (if room (format "/rooms/%s" (ement-room-id room)) ""))
(endpoint (format "user/%s%s/account_data/%s" (url-hexify-string user-id) room-part type)))
(ement-api session endpoint :method 'put :data (json-encode data)
:then then)))
(defun ement-redact (event room session &optional reason)
"Redact EVENT in ROOM on SESSION, optionally for REASON."
(pcase-let* (((cl-struct ement-event (id event-id)) event)
((cl-struct ement-room (id room-id)) room)
(endpoint (format "rooms/%s/redact/%s/%s"
room-id event-id (ement--update-transaction-id session)))
(content (ement-alist "reason" reason)))
(ement-api session endpoint :method 'put :data (json-encode content)
:then (lambda (_data)
(message "Event %s redacted." event-id)))))
;;;;; Inline functions
(defsubst ement--user-color (user)
"Return USER's color, setting it if necessary.
USER is an `ement-user' struct."
(or (ement-user-color user)
(setf (ement-user-color user)
(ement--prism-color (ement-user-id user)))))
;;;;; Private functions
;; These functions aren't expected to be called by code in other packages (but if that
;; were necessary, they could be renamed accordingly).
;; (defun ement--room-routing (room)
;; "Return a list of servers to route to ROOM through."
;; ;; See <https://spec.matrix.org/v1.2/appendices/#routing>.
;; ;; FIXME: Ensure highest power level user is at least level 50.
;; ;; FIXME: Ignore servers blocked due to server ACLs.
;; ;; FIXME: Ignore servers which are IP addresses.
;; (cl-labels ((most-powerful-user-in
;; (room))
;; (servers-by-population-in
;; (room))
;; (server-of (user)))
;; (let (first-server-by-power-level)
;; (delete-dups
;; (remq nil
;; (list
;; ;; 1.
;; (or (when-let ((user (most-powerful-user-in room)))
;; (setf first-server-by-power-level t)
;; (server-of user))
;; (car (servers-by-population-in room)))
;; ;; 2.
;; (if first-server-by-power-level
;; (car (servers-by-population-in room))
;; (cl-second (servers-by-population-in room)))
;; ;; 3.
;; (cl-third (servers-by-population-in room))))))))
(defun ement--room-space-p (room)
"Return non-nil if ROOM is a space."
(equal "m.space" (ement-room-type room)))
(defun ement--room-in-space-p (room space)
"Return non-nil if ROOM is in SPACE on SESSION."
;; We could use `ement---room-spaces', but since that returns rooms by looking them up
;; by ID in the session's rooms list, this is more efficient.
(pcase-let* (((cl-struct ement-room (id parent-id) (local (map children))) space)
((cl-struct ement-room (id child-id) (local (map parents))) room))
(or (member parent-id parents)
(member child-id children))))
(defun ement--room-spaces (room session)
"Return list of ROOM's parent spaces on SESSION."
;; NOTE: This only looks in the room's parents list; it doesn't look in every space's children
;; list. This should be good enough, assuming we add to the lists correctly elsewhere.
(pcase-let* (((cl-struct ement-session rooms) session)
((cl-struct ement-room (local (map parents))) room))
(cl-remove-if-not (lambda (session-room-id)
(member session-room-id parents))
rooms :key #'ement-room-id)))
(cl-defun ement--prism-color (string &key (contrast-with (face-background 'default nil 'default)))
"Return a computed color for STRING.
The color is adjusted to have sufficient contrast with the color
CONTRAST-WITH (by default, the default face's background). The
computed color is useful for user messages, generated room
avatars, etc."
;; TODO: Use this instead of `ement-room--user-color'. (Same algorithm ,just takes a
;; string as argument.)
;; TODO: Try using HSV somehow so we could avoid having so many strings return a
;; nearly-black color.
(cl-labels ((relative-luminance
;; Copy of `modus-themes-wcag-formula', an elegant
;; implementation by Protesilaos Stavrou. Also see
;; <https://en.wikipedia.org/wiki/Relative_luminance> and
;; <https://www.w3.org/TR/WCAG20/#relativeluminancedef>.
(rgb) (cl-loop for k in '(0.2126 0.7152 0.0722)
for x in rgb
sum (* k (if (<= x 0.03928)
(/ x 12.92)
(expt (/ (+ x 0.055) 1.055) 2.4)))))
(contrast-ratio
;; Copy of `modus-themes-contrast'; see above.
(a b) (let ((ct (/ (+ (relative-luminance a) 0.05)
(+ (relative-luminance b) 0.05))))
(max ct (/ ct))))
(increase-contrast
(color against target toward)
(let ((gradient (cdr (color-gradient color toward 20)))
new-color)
(cl-loop do (setf new-color (pop gradient))
while new-color
until (>= (contrast-ratio new-color against) target)
;; Avoid infinite loop in case of weirdness
;; by returning color as a fallback.
finally return (or new-color color)))))
(let* ((id string)
(id-hash (float (+ (abs (sxhash id)) ement-room-prism-color-adjustment)))
;; TODO: Wrap-around the value to get the color I want.
(ratio (/ id-hash (float most-positive-fixnum)))
(color-num (round (* (* 255 255 255) ratio)))
(color-rgb (list (/ (float (logand color-num 255)) 255)
(/ (float (lsh (logand color-num 65280) -8)) 255)
(/ (float (lsh (logand color-num 16711680) -16)) 255)))
(contrast-with-rgb (color-name-to-rgb contrast-with)))
(when (< (contrast-ratio color-rgb contrast-with-rgb) ement-room-prism-minimum-contrast)
(setf color-rgb (increase-contrast color-rgb contrast-with-rgb ement-room-prism-minimum-contrast
(color-name-to-rgb
;; Ideally we would use the foreground color,
;; but in some themes, like Solarized Dark,
;; the foreground color's contrast is too low
;; to be effective as the value to increase
;; contrast against, so we use white or black.
(pcase contrast-with
((or `nil "unspecified-bg")
;; The `contrast-with' color (i.e. the
;; default background color) is nil. This
;; probably means that we're displaying on
;; a TTY.
(if (fboundp 'frame--current-backround-mode)
;; This function can tell us whether
;; the background color is dark or
;; light, but it was added in Emacs
;; 28.1.
(pcase (frame--current-backround-mode (selected-frame))
('dark "white")
('light "black"))
;; Pre-28.1: Since faces' colors may be
;; "unspecified" on TTY frames, in which
;; case we have nothing to compare with, we
;; assume that the background color of such
;; a frame is black and increase contrast
;; toward white.
"white"))
(_
;; The `contrast-with` color is usable: test it.
(if (ement--color-dark-p (color-name-to-rgb contrast-with))
"white" "black")))))))
(apply #'color-rgb-to-hex (append color-rgb (list 2))))))
(cl-defun ement--format-user (user &optional (room ement-room) (session ement-session))
"Format `ement-user' USER for ROOM on SESSION.
ROOM defaults to the value of `ement-room'."
(let ((face (cond ((equal (ement-user-id (ement-session-user session))
(ement-user-id user))
'ement-room-self)
(ement-room-prism
`(:inherit ement-room-user :foreground ,(or (ement-user-color user)
(setf (ement-user-color user)
(ement--prism-color user)))))
(t 'ement-room-user))))
;; FIXME: If a membership state event has not yet been received, this
;; sets the display name in the room to the user ID, and that prevents
;; the display name from being used if the state event arrives later.
(propertize (ement--user-displayname-in room user)
'face face
'help-echo (ement-user-id user))))
(cl-defun ement--format-body-mentions
(body room &key (template "<a href=\"https://matrix.to/#/%s\">%s</a>"))
"Return string for BODY with mentions in ROOM linkified with TEMPLATE.
TEMPLATE is a format string in which the first \"%s\" is replaced
with the user's MXID and the second with the displayname. A
mention is qualified by an \"@\"-prefixed displayname or
MXID (optionally suffixed with a colon), or a colon-suffixed
displayname, followed by a blank, question mark, comma, or
period, anywhere in the body."
;; Examples:
;; "@foo: hi"
;; "@foo:matrix.org: hi"
;; "foo: hi"
;; "@foo and @bar:matrix.org: hi"
;; "foo: how about you and @bar ..."
(declare (indent defun))
(cl-labels ((members-having-displayname
;; Iterating over the hash table values isn't as efficient as a hash
;; lookup, but in most rooms it shouldn't be a problem.
(name members) (cl-loop for user being the hash-values of members
when (equal name (ement--user-displayname-in room user))
collect user)))
(pcase-let* (((cl-struct ement-room members) room)
(regexp (rx (or bos bow (1+ blank))
(or (seq (group
;; Group 1: full @-prefixed MXID.
"@" (group
;; Group 2: displayname. (NOTE: Does not work
;; with displaynames containing spaces.)
(1+ (seq (optional ".") alnum)))
(optional ":" (1+ (seq (optional ".") alnum))))
(or ":" eow eos (syntax punctuation)))
(seq (group
;; Group 3: MXID username or displayname.
(1+ (not blank)))
":" (1+ blank)))))
(pos 0) (replace-group) (replacement))
(while (setf pos (string-match regexp body pos))
(if (setf replacement
(or (when-let (member (gethash (match-string 1 body) members))
;; Found user ID: use it as replacement.
(setf replace-group 1)
(format template (match-string 1 body)
(ement--xml-escape-string (ement--user-displayname-in room member))))
(when-let* ((name (or (when (match-string 2 body)
(setf replace-group 1)
(match-string 2 body))
(prog1 (match-string 3 body)
(setf replace-group 3))))
(members (members-having-displayname name members))
(member (when (= 1 (length members))
;; If multiple members are found with the same
;; displayname, do nothing.
(car members))))
;; Found displayname: use it and MXID as replacement.
(format template (ement-user-id member)
(ement--xml-escape-string name)))))
(progn
;; Found member: replace and move to end of replacement.
(setf body (replace-match replacement t t body replace-group))
(let ((difference (- (length replacement) (length (match-string 0 body)))))
(setf pos (if (/= 0 difference)
;; Replacement of a different length: adjust POS accordingly.
(+ pos difference)
(match-end 0)))))
;; No replacement: move to end of match.
(setf pos (match-end 0))))))
body)
(defun ement--event-mentions-room-p (event &rest _ignore)
"Return non-nil if EVENT mentions \"@room\"."
(pcase-let (((cl-struct ement-event (content (map body))) event))
(when body
(string-match-p (rx (or space bos) "@room" eow) body))))
(cl-defun ement-complete-room (&key session (predicate #'identity)
(prompt "Room: ") (suggest t))
"Return a (room session) list selected from SESSION with completion.
If SESSION is nil, select from rooms in all of `ement-sessions'.
When SUGGEST, suggest current buffer's room (or a room at point
in a room list buffer) as initial input (i.e. it should be set to
nil when switching from one room buffer to another). PROMPT may
override the default prompt. PREDICATE may be a function to
select which rooms are offered; it is also applied to the
suggested room."
(declare (indent defun))
(pcase-let* ((sessions (if session
(list session)
(mapcar #'cdr ement-sessions)))
(name-to-room-session
(cl-loop for session in sessions
append (cl-loop for room in (ement-session-rooms session)
when (funcall predicate room)
collect (cons (ement--format-room room 'topic)
(list room session)))))
(names (mapcar #'car name-to-room-session))
(selected-name (completing-read
prompt names nil t
(when suggest
(when-let ((suggestion (ement--room-at-point)))
(when (or (not predicate)
(funcall predicate suggestion))
(ement--format-room suggestion 'topic)))))))
(alist-get selected-name name-to-room-session nil nil #'string=)))
(cl-defun ement-send-message (room session
&key body formatted-body replying-to-event filter then)
"Send message to ROOM on SESSION with BODY and FORMATTED-BODY.
THEN may be a function to call after the event is sent
successfully. It is called with keyword arguments for ROOM,
SESSION, CONTENT, and DATA.
REPLYING-TO-EVENT may be an event the message is
in reply to; the message will reference it appropriately.
FILTER may be a function through which to pass the message's
content object before sending (see,
e.g. `ement-room-send-org-filter')."
(declare (indent defun))
(cl-assert (not (string-empty-p body)))
(cl-assert (or (not formatted-body) (not (string-empty-p formatted-body))))
(pcase-let* (((cl-struct ement-room (id room-id)) room)
(endpoint (format "rooms/%s/send/m.room.message/%s" (url-hexify-string room-id)
(ement--update-transaction-id session)))
(formatted-body (when formatted-body
(ement--format-body-mentions formatted-body room)))
(content (ement-aprog1
(ement-alist "msgtype" "m.text"
"body" body)
(when formatted-body
(push (cons "formatted_body" formatted-body) it)
(push (cons "format" "org.matrix.custom.html") it))))
(then (or then #'ignore)))
(when filter
(setf content (funcall filter content room)))
(when replying-to-event
(setf content (ement--add-reply content replying-to-event room)))
(ement-api session endpoint :method 'put :data (json-encode content)
:then (apply-partially then :room room :session session
;; Data is added when calling back.
:content content :data))))
(defalias 'ement--button-buttonize
;; FIXME: This doesn't set the mouse-face to highlight, and it doesn't use the
;; default-button category. Neither does `button-buttonize', of course, but why?
(if (version< emacs-version "28.1")
(lambda (string callback &optional data)
"Make STRING into a button and return it.
When clicked, CALLBACK will be called with the DATA as the
function argument. If DATA isn't present (or is nil), the button
itself will be used instead as the function argument."
(propertize string
'face 'button
'button t
'follow-link t
'category t
'button-data data
'keymap button-map
'action callback))
#'button-buttonize))
(defun ement--add-reply (data replying-to-event room)
"Return DATA adding reply data for REPLYING-TO-EVENT in ROOM.
DATA is an unsent message event's data alist."
;; SPEC: <https://matrix.org/docs/spec/client_server/r0.6.1#id351> "13.2.2.6.1 Rich replies"
;; FIXME: Rename DATA.
(pcase-let* (((cl-struct ement-event (id replying-to-event-id)
content (sender replying-to-sender))
replying-to-event)
((cl-struct ement-user (id replying-to-sender-id)) replying-to-sender)
((map ('body replying-to-body) ('formatted_body replying-to-formatted-body)) content)
(replying-to-sender-name (ement--user-displayname-in ement-room replying-to-sender))
(quote-string (format "> <%s> %s\n\n" replying-to-sender-name replying-to-body))
(reply-body (alist-get "body" data nil nil #'string=))
(reply-formatted-body (alist-get "formatted_body" data nil nil #'string=))
(reply-body-with-quote (concat quote-string reply-body))
(reply-formatted-body-with-quote
(format "<mx-reply>
<blockquote>
<a href=\"https://matrix.to/#/%s/%s\">In reply to</a>
<a href=\"https://matrix.to/#/%s\">%s</a>
<br />
%s
</blockquote>
</mx-reply>
%s"
(ement-room-id room) replying-to-event-id replying-to-sender-id replying-to-sender-name
;; TODO: Encode HTML special characters. Not as straightforward in Emacs as one
;; might hope: there's `web-mode-html-entities' and `org-entities'. See also
;; <https://emacs.stackexchange.com/questions/8166/encode-non-html-characters-to-html-equivalent>.
(or replying-to-formatted-body replying-to-body)
(or reply-formatted-body reply-body))))
;; NOTE: map-elt doesn't work with string keys, so we use `alist-get'.
(setf (alist-get "body" data nil nil #'string=) reply-body-with-quote
(alist-get "formatted_body" data nil nil #'string=) reply-formatted-body-with-quote
data (append (ement-alist "m.relates_to"
(ement-alist "m.in_reply_to"
(ement-alist "event_id" replying-to-event-id))
"format" "org.matrix.custom.html")
data))
data))
(defun ement--direct-room-for-user (user session)
"Return last-modified direct room with USER on SESSION, if one exists."
;; Loosely modeled on the Element function findDMForUser in createRoom.ts.
(cl-labels ((membership-event-for-p
(event user) (and (equal "m.room.member" (ement-event-type event))
(equal (ement-user-id user) (ement-event-state-key event))))
(latest-membership-for
(user room)
(when-let ((latest-membership-event
(car
(cl-sort
;; I guess we need to check both state and timeline events.
(append (cl-remove-if-not (lambda (event)
(membership-event-for-p event user))
(ement-room-state room))
(cl-remove-if-not (lambda (event)
(membership-event-for-p event user))
(ement-room-timeline room)))
(lambda (a b)
;; Sort latest first so we can use the car.
(> (ement-event-origin-server-ts a)
(ement-event-origin-server-ts b)))))))
(alist-get 'membership (ement-event-content latest-membership-event))))
(latest-event-in
(room) (car
(cl-sort
(append (ement-room-state room)
(ement-room-timeline room))
(lambda (a b)
;; Sort latest first so we can use the car.
(> (ement-event-origin-server-ts a)
(ement-event-origin-server-ts b)))))))
(let* ((direct-rooms (cl-remove-if-not
(lambda (room)
(ement--room-direct-p room session))
(ement-session-rooms session)))
(direct-joined-rooms
;; Ensure that the local user is still in each room.
(cl-remove-if-not
(lambda (room)
(equal "join" (latest-membership-for (ement-session-user session) room)))
direct-rooms))
;; Since we don't currently keep a member list for each room, we look in the room's
;; join events to see if the user has joined or been invited.
(direct-rooms-with-user
(cl-remove-if-not
(lambda (room)
(member (latest-membership-for user room) '("invite" "join")))
direct-joined-rooms)))
(car (cl-sort direct-rooms-with-user
(lambda (a b)
(> (latest-event-in a) (latest-event-in b))))))))
(defun ement--event-replaces-p (a b)
"Return non-nil if event A replaces event B.
That is, if event A replaces B in their
\"m.relates_to\"/\"m.relations\" and \"m.replace\" metadata."
(pcase-let* (((cl-struct ement-event (id a-id)
(content (map ('m.relates_to
(map ('rel_type a-rel-type)
('event_id a-replaces-event-id))))))
a)
((cl-struct ement-event (id b-id)
;; Not sure why this ends up in the unsigned key, but it does.
(unsigned (map ('m.relations
(map ('m.replace
(map ('event_id b-replaced-by-event-id))))))))
b))
(or (and (equal "m.replace" a-rel-type)
(equal a-replaces-event-id b-id))
(equal a-id b-replaced-by-event-id))))
(defun ement--events-equal-p (a b)
"Return non-nil if events A and B are essentially equal.
That is, A and B are either the same event (having the same event
ID), or one event replaces the other (in their m.relates_to and
m.replace metadata)."
(or (equal (ement-event-id a) (ement-event-id b))
(ement--event-replaces-p a b)
(ement--event-replaces-p b a)))
(defun ement--format-room (room &optional topic)
"Return ROOM formatted with name, alias, ID, and optionally TOPIC.
Suitable for use in completion, etc."
(if topic
(format "%s%s(<%s>)%s"
(or (ement-room-display-name room)
(setf (ement-room-display-name room)
(ement--room-display-name room)))
(if (ement-room-canonical-alias room)
(format " <%s> " (ement-room-canonical-alias room))
" ")
(ement-room-id room)
(if (ement-room-topic room)
(format ": \"%s\"" (ement-room-topic room))
""))
(format "%s%s(<%s>)"
(or (ement-room-display-name room)
(setf (ement-room-display-name room)
(ement--room-display-name room)))
(if (ement-room-canonical-alias room)
(format " <%s> " (ement-room-canonical-alias room))
" ")
(ement-room-id room))))
(defun ement--members-alist (room)
"Return alist of member displaynames mapped to IDs seen in ROOM."
;; We map displaynames to IDs because `ement-room--format-body-mentions' needs to find
;; MXIDs from displaynames.
(pcase-let* (((cl-struct ement-room timeline) room)
(members-seen (mapcar #'ement-event-sender timeline))
(members-alist))
(dolist (member members-seen)
;; Testing with `benchmark-run-compiled', it appears that using `cl-pushnew' is
;; about 10x faster than using `delete-dups'.
(cl-pushnew (cons (ement--user-displayname-in room member)
(ement-user-id member))
members-alist))
members-alist))
(defun ement--mxc-to-url (uri session)
"Return HTTPS URL for MXC URI accessed through SESSION."
(pcase-let* (((cl-struct ement-session server) session)
((cl-struct ement-server uri-prefix) server)
(server-name) (media-id))
(string-match (rx "mxc://" (group (1+ (not (any "/"))))
"/" (group (1+ anything))) uri)
(setf server-name (match-string 1 uri)
media-id (match-string 2 uri))
(format "%s/_matrix/media/r0/download/%s/%s"
uri-prefix server-name media-id)))
(defun ement--remove-face-property (string value)
"Remove VALUE from STRING's `face' properties.
Used to remove the `button' face from buttons, because that face
can cause undesirable underlining."
(let ((pos 0))
(cl-loop for next-face-change-pos = (next-single-property-change pos 'face string)
for face-at = (get-text-property pos 'face string)
when face-at
do (put-text-property pos (or next-face-change-pos (length string))
'face (cl-typecase face-at
(atom (if (equal value face-at)
nil face-at))
(list (remove value face-at)))
string)
while next-face-change-pos
do (setf pos next-face-change-pos))))
(defun ement--resize-image (image max-width max-height)
"Return a copy of IMAGE set to MAX-WIDTH and MAX-HEIGHT.
IMAGE should be one as created by, e.g. `create-image'."
;; It would be nice if the image library had some simple functions to do this sort of thing.
(let ((new-image (cl-copy-list image)))
(when (fboundp 'imagemagick-types)
;; Only do this when ImageMagick is supported.
;; FIXME: When requiring Emacs 27+, remove this (I guess?).
(setf (image-property new-image :type) 'imagemagick))
(setf (image-property new-image :max-width) max-width
(image-property new-image :max-height) max-height)
new-image))
(defun ement--room-alias (room)
"Return latest m.room.canonical_alias event in ROOM."
;; FIXME: This function probably needs to compare timestamps to ensure that older events
;; that are inserted at the head of the events lists aren't used instead of newer ones.
(or (cl-loop for event in (ement-room-timeline room)
when (equal "m.room.canonical_alias" (ement-event-type event))
return (alist-get 'alias (ement-event-content event)))
(cl-loop for event in (ement-room-state room)
when (equal "m.room.canonical_alias" (ement-event-type event))
return (alist-get 'alias (ement-event-content event)))))
(declare-function magit-current-section "magit-section")
(declare-function eieio-oref "eieio-core")
(defun ement--room-at-point ()
"Return room at point.
Works in major-modes `ement-room-mode',
`ement-tabulated-room-list-mode', and `ement-room-list-mode'."
(pcase major-mode
('ement-room-mode ement-room)
('ement-tabulated-room-list-mode (tabulated-list-get-id))
('ement-room-list-mode
(cl-typecase (oref (magit-current-section) value)
(taxy-magit-section nil)
(t (pcase (oref (magit-current-section) value)
(`[,room ,_session] room)))))))
(defun ement--room-direct-p (room session)
"Return non-nil if ROOM on SESSION is a direct chat."
(cl-labels ((content-contains-room-id
(content room-id) (cl-loop for (_user-id . room-ids) in content
;; NOTE: room-ids is a vector.
thereis (seq-contains-p room-ids room-id))))
(pcase-let* (((cl-struct ement-session account-data) session)
((cl-struct ement-room id) room))
(or (cl-loop for event in account-data
when (equal "m.direct" (alist-get 'type event))
thereis (content-contains-room-id (alist-get 'content event) id))
(cl-loop
;; Invited rooms have no account-data yet, and their
;; directness flag is in invite-state events.
for event in (ement-room-invite-state room)
thereis (alist-get 'is_direct (ement-event-content event)))))))
(defun ement--room-display-name (room)
"Return the displayname for ROOM."
;; SPEC: <https://matrix.org/docs/spec/client_server/r0.6.1#calculating-the-display-name-for-a-room>.
;; NOTE: The spec seems incomplete, because the algorithm it recommends does not say how
;; or when to use "m.room.member" events for rooms without heroes (e.g. invited rooms).
;; TODO: Add SESSION argument and use it to remove local user from names.
(cl-labels ((latest-event (type content-field)
(or (cl-loop for event in (ement-room-timeline room)
when (and (equal type (ement-event-type event))
(not (string-empty-p (alist-get content-field (ement-event-content event)))))
return (alist-get content-field (ement-event-content event)))
(cl-loop for event in (ement-room-state room)
when (and (equal type (ement-event-type event))
(not (string-empty-p (alist-get content-field (ement-event-content event)))))
return (alist-get content-field (ement-event-content event)))))
(member-events-name
() (when-let ((member-events (cl-loop for accessor in '(ement-room-timeline ement-room-state ement-room-invite-state)
append (cl-remove-if-not (apply-partially #'equal "m.room.member")
(funcall accessor room)
:key #'ement-event-type))))
(string-join (delete-dups
(mapcar (lambda (event)
(ement--user-displayname-in room (ement-event-sender event)))
member-events))
", ")))
(heroes-name
() (pcase-let* (((cl-struct ement-room summary) room)
((map ('m.heroes hero-ids) ('m.joined_member_count joined-count)
('m.invited_member_count invited-count))
summary))
;; TODO: Disambiguate hero display names.
(when hero-ids
(cond ((<= (+ joined-count invited-count) 1)
;; Empty room.
(empty-room hero-ids joined-count))
((>= (length hero-ids) (1- (+ joined-count invited-count)))
;; Members == heroes.
(hero-names hero-ids))
((and (< (length hero-ids) (1- (+ joined-count invited-count)))
(> (+ joined-count invited-count) 1))
;; More members than heroes.
(heroes-and-others hero-ids joined-count))))))
(hero-names
(heroes) (string-join (mapcar #'hero-name heroes) ", "))
(hero-name
(id) (if-let ((user (gethash id ement-users)))
(ement--user-displayname-in room user)
id))
(heroes-and-others
(heroes joined)
(format "%s, and %s others" (hero-names heroes)
(- joined (length heroes))))
(name-override
() (when-let ((event (alist-get "org.matrix.msc3015.m.room.name.override"
(ement-room-account-data room)
nil nil #'equal)))
(map-nested-elt event '(content name))))
(empty-room
(heroes joined) (cl-etypecase (length heroes)
((satisfies zerop) "Empty room")
((number 1 5) (format "Empty room (was %s)"
(hero-names heroes)))
(t (format "Empty room (was %s)"
(heroes-and-others heroes joined))))))
(or (name-override)
(latest-event "m.room.name" 'name)
(latest-event "m.room.canonical_alias" 'alias)
(heroes-name)
(member-events-name)
(ement-room-id room))))
(defun ement--room-favourite-p (room)
"Return non-nil if ROOM is tagged as favourite."
(ement--room-tagged-p "m.favourite" room))
(defun ement--room-low-priority-p (room)
"Return non-nil if ROOM is tagged as low-priority."
(ement--room-tagged-p "m.lowpriority" room))
(defun ement--room-tagged-p (tag room)
"Return non-nil if ROOM has TAG."
;; TODO: Use `make-ement-event' on account-data events.
(pcase-let* (((cl-struct ement-room account-data) room)
(tag-event (alist-get "m.tag" account-data nil nil #'equal)))
(when tag-event
(pcase-let (((map ('content (map tags))) tag-event))
(cl-typecase tag
;; Tags are symbols internally, because `json-read' converts map keys to them.
(string (setf tag (intern tag))))
(assoc tag tags)))))
(defun ement--room-unread-p (room session)
"Return non-nil if ROOM is considered unread for SESSION.
The room is unread if it has a modified, live buffer; if it has
non-zero unread notification counts; or if its fully-read marker
is not at the latest known message event."
;; Roughly equivalent to the "red/gray/bold/idle" states listed in
;; <https://github.com/matrix-org/matrix-react-sdk/blob/b0af163002e8252d99b6d7075c83aadd91866735/docs/room-list-store.md#list-ordering-algorithm-importance>.
(pcase-let* (((cl-struct ement-room timeline account-data unread-notifications receipts
(local (map buffer)))
room)
((cl-struct ement-session user) session)
((cl-struct ement-user (id our-id)) user)
((map notification_count highlight_count) unread-notifications)
(fully-read-event-id (map-nested-elt (alist-get "m.fully_read" account-data nil nil #'equal)
'(content event_id))))
;; MAYBE: Ignore whether the buffer is modified. Since we have a better handle on how
;; Matrix does notifications/unreads/highlights, maybe that's not needed, and it would
;; be more consistent to ignore it.
(or (and buffer (buffer-modified-p buffer))
(and unread-notifications
(or (not (zerop notification_count))
(not (zerop highlight_count))))
;; NOTE: This is *WAY* too complicated, but it seems roughly equivalent to doesRoomHaveUnreadMessages() from
;; <https://github.com/matrix-org/matrix-react-sdk/blob/7fa01ffb068f014506041bce5f02df4f17305f02/src/Unread.ts#L52>.
(when (and (not ement-room-unread-only-counts-notifications)
timeline)
;; A room should rarely, if ever, have a nil timeline, but in case it does
;; (which apparently can happen, given user reports), it should not be
;; considered unread.
(cl-labels ((event-counts-toward-unread-p
;; NOTE: We only consider message events, so membership, reaction,
;; etc. events will not mark a room as unread. Ideally, I think
;; that join/leave events should, at least optionally, mark a room
;; as unread (e.g. in a 1:1 room with a friend, if the other user
;; left, one would probably want to know, and marking the room
;; unread would help the user notice), but since membership events
;; have to be processed to understand their meaning, it's not
;; straightforward to know whether one should mark a room unread.
;; FIXME: Use code from `ement-room--format-member-event' to
;; distinguish ones that should count.
(event) (equal "m.room.message" (ement-event-type event))))
(let ((our-read-receipt-event-id (car (gethash our-id receipts)))
(first-counting-event (cl-find-if #'event-counts-toward-unread-p timeline)))
(cond ((equal fully-read-event-id (ement-event-id (car timeline)))
;; The fully-read marker is at the last known event: the room is read.
nil)
((and (not our-read-receipt-event-id)
(when first-counting-event
(and (not (equal fully-read-event-id (ement-event-id first-counting-event)))
(not (equal our-id (ement-user-id (ement-event-sender first-counting-event)))))))
;; The room has no read receipt, and the latest message event is not
;; the event at which our fully-read marker is at, and it is not sent
;; by us: the room is unread. (This is a kind of failsafe to ensure
;; the user doesn't miss any messages, but it's unclear whether this
;; is really correct or best.)
t)
((equal our-id (ement-user-id (ement-event-sender (car timeline))))
;; We sent the last event: the room is read.
nil)
((and first-counting-event
(equal our-id (ement-user-id (ement-event-sender first-counting-event))))
;; We sent the last message event: the room is read.
nil)
((cl-loop for event in timeline
when (event-counts-toward-unread-p event)
return (and (not (equal our-read-receipt-event-id (ement-event-id event)))
(not (equal fully-read-event-id (ement-event-id event)))))
;; The latest message event is not the event at which our
;; read-receipt or fully-read marker are at: the room is unread.
t))))))))
(defun ement--update-transaction-id (session)
"Return SESSION's incremented transaction ID formatted for sending.
Increments ID and appends current timestamp to avoid reuse
problems."
;; TODO: Naming things is hard.
;; In the event that Emacs isn't killed cleanly and the session isn't saved to disk, the
;; transaction ID would get reused the next time the user connects. To avoid that, we
;; append the current time to the ID. (IDs are just strings, and Element does something
;; similar, so this seems reasonable.)
(format "%s-%s"
(cl-incf (ement-session-transaction-id session))
(format-time-string "%s")))
(defun ement--user-displayname-in (room user)
"Return the displayname for USER in ROOM."
;; SPEC: <https://matrix.org/docs/spec/client_server/r0.6.1#calculating-the-display-name-for-a-user>.
;; FIXME: Add step 3 of the spec. For now we skip to step 4.
;; NOTE: Both state and timeline events must be searched. (A helpful user
;; in #matrix-dev:matrix.org, Michael (t3chguy), clarified this for me).
(if-let ((cached-name (gethash user (ement-room-displaynames room))))
cached-name
;; Put timeline events before state events, because IIUC they should be more recent.
(cl-labels ((join-displayname-event-p
(event) (and (eq user (ement-event-sender event))
(equal "m.room.member" (ement-event-type event))
(equal "join" (alist-get 'membership (ement-event-content event)))
(alist-get 'displayname (ement-event-content event)))))
;; FIXME: Should probably sort the relevant events to get the latest one.
(if-let* ((displayname (or (cl-loop for event in (ement-room-timeline room)
when (join-displayname-event-p event)
return (alist-get 'displayname (ement-event-content event)))
(cl-loop for event in (ement-room-state room)
when (join-displayname-event-p event)
return (alist-get 'displayname (ement-event-content event)))))
(calculated-name displayname))
(puthash user calculated-name (ement-room-displaynames room))
;; No membership state event: use pre-calculated displayname or ID.
(or (ement-user-displayname user)
(ement-user-id user))))))
(defun ement--xml-escape-string (string)
"Return STRING having been escaped with `xml-escape-string'.
Before Emacs 28, ignores `xml-invalid-character' errors (and any
invalid characters cause STRING to remain unescaped). After
Emacs 28, uses the NOERROR argument to `xml-escape-string'."
(condition-case _
(xml-escape-string string 'noerror)
(wrong-number-of-arguments
(condition-case _
(xml-escape-string string)
(xml-invalid-character
;; We still don't want to error on this, so just return the string.
string)))))
(defun ement--mark-room-direct (room session)
"Mark ROOM on SESSION as a direct room.
This may be used to mark rooms as direct which, for whatever
reason (like a bug in your favorite client), were not marked as
such when they were created."
(pcase-let* (((cl-struct ement-room timeline (id room-id)) room)
((cl-struct ement-session (user local-user)) session)
((cl-struct ement-user (id local-user-id)) local-user)
(direct-rooms-account-data-event-content
(alist-get 'content
(cl-find-if (lambda (event)
(equal "m.direct" (alist-get 'type event)))
(ement-session-account-data session))))
(members (delete-dups (mapcar #'ement-event-sender timeline)))
(other-users (cl-remove local-user-id members
:key #'ement-user-id :test #'equal))
((cl-struct ement-user (id other-user-id)) (car other-users))
;; The alist keys are MXIDs as symbols.
(other-user-id (intern other-user-id))
(existing-direct-rooms-for-user (map-elt direct-rooms-account-data-event-content other-user-id)))
(cl-assert (= 1 (length other-users)))
(setf (map-elt direct-rooms-account-data-event-content other-user-id)
(cl-coerce (append existing-direct-rooms-for-user (list room-id))
'vector))
(ement-put-account-data session "m.direct" direct-rooms-account-data-event-content
:then (lambda (_data)
(message "Ement: Room <%s> marked as direct for <%s>." room-id other-user-id)))
(message "Ement: Marking room as direct...")))
(cl-defun ement--get-joined-members (room session &key then else)
"Get joined members in ROOM on SESSION and call THEN with response data.
Or call ELSE with error data if request fails. Also puts members
on `ement-users', updating their displayname and avatar URL
slots, and puts them on ROOM's `members' table."
(declare (indent defun))
(pcase-let* (((cl-struct ement-room id members) room)
(endpoint (format "rooms/%s/joined_members" (url-hexify-string id))))
(ement-api session endpoint
:else else
:then (lambda (data)
(clrhash members)
(mapc (lambda (member)
(pcase-let* ((`(,id-symbol
. ,(map ('avatar_url avatar-url)
('display_name display-name)))
member)
(member-id (symbol-name id-symbol))
(user (or (gethash member-id ement-users)
(puthash member-id (make-ement-user :id member-id)
ement-users))))
(setf (ement-user-displayname user) display-name
(ement-user-avatar-url user) avatar-url)
(puthash member-id user members)))
(alist-get 'joined data))
(setf (alist-get 'fetched-members-p (ement-room-local room)) t)
(when then
;; Finally, call the given callback.
(funcall then data))))
(message "Ement: Getting joined members in %s..." (ement--format-room room))))
(cl-defun ement--human-format-duration (seconds &optional abbreviate)
"Return human-formatted string describing duration SECONDS.
If SECONDS is less than 1, returns \"0 seconds\". If ABBREVIATE
is non-nil, return a shorter version, without spaces. This is a
simple calculation that does not account for leap years, leap
seconds, etc."
;; Copied from `ts-human-format-duration' (same author).
(if (< seconds 1)
(if abbreviate "0s" "0 seconds")
(cl-macrolet ((format> (place)
;; When PLACE is greater than 0, return formatted string using its symbol name.
`(when (> ,place 0)
(format "%d%s%s" ,place
(if abbreviate "" " ")
(if abbreviate
,(substring (symbol-name place) 0 1)
,(symbol-name place)))))
(join-places (&rest places)
;; Return string joining the names and values of PLACES.
`(string-join (delq nil
(list ,@(cl-loop for place in places
collect `(format> ,place))))
(if abbreviate "" ", "))))
(pcase-let ((`(,years ,days ,hours ,minutes ,seconds) (ement--human-duration seconds)))
(join-places years days hours minutes seconds)))))
(defun ement--human-duration (seconds)
"Return list describing duration SECONDS.
List includes years, days, hours, minutes, and seconds. This is
a simple calculation that does not account for leap years, leap
seconds, etc."
;; Copied from `ts-human-format-duration' (same author).
(cl-macrolet ((dividef (place divisor)
;; Divide PLACE by DIVISOR, set PLACE to the remainder, and return the quotient.
`(prog1 (/ ,place ,divisor)
(setf ,place (% ,place ,divisor)))))
(let* ((seconds (floor seconds))
(years (dividef seconds 31536000))
(days (dividef seconds 86400))
(hours (dividef seconds 3600))
(minutes (dividef seconds 60)))
(list years days hours minutes seconds))))
;;; Footer
(provide 'ement-lib)
;;; ement-lib.el ends here
;;; ement-directory.el --- Public room directory support -*- lexical-binding: t; -*-
;; Copyright (C) 2022 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; This library provides support for viewing and searching public room directories on
;; Matrix homeservers.
;; To make rendering the list flexible and useful, we'll use `taxy-magit-section'.
;;; Code:
;;;; Requirements
(require 'ement)
(require 'ement-room-list)
(require 'taxy)
(require 'taxy-magit-section)
;;;; Variables
(defvar ement-directory-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'ement-directory-RET)
(define-key map [mouse-1] #'ement-directory-mouse-1)
(define-key map (kbd "+") #'ement-directory-next)
map))
(defgroup ement-directory nil
"Options for room directories."
:group 'ement)
;;;; Mode
(define-derived-mode ement-directory-mode magit-section-mode "Ement-Directory"
:global nil)
(defvar-local ement-directory-etc nil
"Alist storing information in `ement-directory' buffers.")
;;;;; Keys
(eval-and-compile
(taxy-define-key-definer ement-directory-define-key
ement-directory-keys "ement-directory-key" "FIXME: Docstring."))
;; TODO: Other keys like guest_can_join, world_readable, etc. (Last-updated time would be
;; nice, but the server doesn't include that in the results.)
(ement-directory-define-key joined-p ()
(pcase-let (((map ('room_id id)) item)
((map session) ement-directory-etc))
(when (cl-find id (ement-session-rooms session)
:key #'ement-room-id :test #'equal)
"Joined")))
(ement-directory-define-key size (&key < >)
(pcase-let (((map ('num_joined_members size)) item))
(cond ((and < (< size <))
(format "< %s members" <))
((and > (> size >))
(format "> %s members" >)))))
(ement-directory-define-key space-p ()
"Groups rooms that are themselves spaces."
(pcase-let (((map ('room_type type)) item))
(when (equal "m.space" type)
"Spaces")))
(defcustom ement-directory-default-keys
'((joined-p)
(space-p)
((size :> 10000))
((size :> 1000))
((size :> 100))
((size :> 10))
((size :< 11)))
"Default keys."
:type 'sexp)
;;;; Columns
(defvar-local ement-directory-room-avatar-cache (make-hash-table)
;; Use a buffer-local variable so that the cache is cleared when the buffer is closed.
"Hash table caching room avatars for the `ement-directory' room list.")
(eval-and-compile
(taxy-magit-section-define-column-definer "ement-directory"))
;; TODO: Fetch avatars (with queueing and async updating/insertion?).
(ement-directory-define-column #("✓" 0 1 (help-echo "Joined")) ()
(pcase-let (((map ('room_id id)) item)
((map session) ement-directory-etc))
(if (cl-find id (ement-session-rooms session)
:key #'ement-room-id :test #'equal)
"✓"
" ")))
(ement-directory-define-column "Name" (:max-width 25)
(pcase-let* (((map name ('room_type type)) item)
(face (pcase type
("m.space" 'ement-room-list-space)
(_ 'ement-room-list-name))))
(propertize (or name "[unnamed]")
'face face)))
(ement-directory-define-column "Alias" (:max-width 25)
(pcase-let (((map ('canonical_alias alias)) item))
(or alias "")))
(ement-directory-define-column "Size" ()
(pcase-let (((map ('num_joined_members size)) item))
(number-to-string size)))
(ement-directory-define-column "Topic" (:max-width 50)
(pcase-let (((map topic) item))
(if topic
(replace-regexp-in-string "\n" " | " topic nil t)
"")))
(ement-directory-define-column "ID" ()
(pcase-let (((map ('room_id id)) item))
id))
(unless ement-directory-columns
;; TODO: Automate this or document it
(setq-default ement-directory-columns
'("Name" "Alias" "Size" "Topic" "ID")))
;;;; Commands
;; TODO: Pagination of results.
;;;###autoload
(cl-defun ement-directory (&key server session since (limit 100))
"View the public room directory on SERVER with SESSION.
Show up to LIMIT rooms. Interactively, with prefix, prompt for
server and LIMIT.
SINCE may be a next-batch token."
(interactive (let* ((session (ement-complete-session :prompt "Search on session: "))
(server (if current-prefix-arg
(read-string "Search on server: " nil nil
(ement-server-name (ement-session-server session)))
(ement-server-name (ement-session-server session))))
(args (list :server server :session session)))
(when current-prefix-arg
(cl-callf plist-put args
:limit (read-number "Limit number of rooms: " 100)))
args))
(pcase-let ((revert-function (lambda (&rest _ignore)
(interactive)
(ement-directory :server server :session session :limit limit)))
(endpoint "publicRooms")
(params (list (list "limit" limit))))
(when since
(cl-callf append params (list (list "since" since))))
(ement-api session endpoint :params params
:then (lambda (results)
(pcase-let (((map ('chunk rooms) ('next_batch next-batch)
('total_room_count_estimate remaining))
results))
(ement-directory--view rooms :append-p since
:buffer-name (format "*Ement Directory: %s*" server)
:root-section-name (format "Ement Directory: %s" server)
:init-fn (lambda ()
(setf (alist-get 'server ement-directory-etc) server
(alist-get 'session ement-directory-etc) session
(alist-get 'next-batch ement-directory-etc) next-batch
(alist-get 'limit ement-directory-etc) limit)
(setq-local revert-buffer-function revert-function)
(when remaining
;; FIXME: The server seems to report all of the rooms on
;; the server as remaining even when searching for a
;; specific term like "emacs".
;; TODO: Display this in a more permanent place (like a
;; header or footer).
(message
(substitute-command-keys
"%s rooms remaining (use \\[ement-directory-next] to fetch more)")
remaining)))))))
(ement-message "Listing %s rooms on %s..." limit server)))
;;;###autoload
(cl-defun ement-directory-search (query &key server session since (limit 1000))
"View public rooms on SERVER matching QUERY.
QUERY is a string used to filter results."
(interactive (let* ((session (ement-complete-session :prompt "Search on session: "))
(server (if current-prefix-arg
(read-string "Search on server: " nil nil
(ement-server-name (ement-session-server session)))
(ement-server-name (ement-session-server session))))
(query (read-string (format "Search for rooms on %s matching: " server)))
(args (list query :server server :session session)))
(when current-prefix-arg
(cl-callf plist-put (cdr args)
:limit (read-number "Limit number of rooms: " 1000)))
args))
;; TODO: Handle "include_all_networks" and "third_party_instance_id". See § 10.5.4.
(pcase-let* ((revert-function (lambda (&rest _ignore)
(interactive)
(ement-directory-search query :server server :session session)))
(endpoint "publicRooms")
(data (rassq-delete-all nil
(ement-alist "limit" limit
"filter" (ement-alist "generic_search_term" query)
"since" since))))
(ement-api session endpoint :method 'post :data (json-encode data)
:then (lambda (results)
(pcase-let (((map ('chunk rooms) ('next_batch next-batch)
('total_room_count_estimate remaining))
results))
(ement-directory--view rooms :append-p since
:buffer-name (format "*Ement Directory: \"%s\" on %s*" query server)
:root-section-name (format "Ement Directory: \"%s\" on %s" query server)
:init-fn (lambda ()
(setf (alist-get 'server ement-directory-etc) server
(alist-get 'session ement-directory-etc) session
(alist-get 'next-batch ement-directory-etc) next-batch
(alist-get 'limit ement-directory-etc) limit
(alist-get 'query ement-directory-etc) query)
(setq-local revert-buffer-function revert-function)
(when remaining
(message
(substitute-command-keys
"%s rooms remaining (use \\[ement-directory-next] to fetch more)")
remaining)))))))
(ement-message "Searching for %S on %s..." query server)))
(defun ement-directory-next ()
"Fetch next batch of results in `ement-directory' buffer."
(interactive)
(pcase-let (((map next-batch query limit server session) ement-directory-etc))
(unless next-batch
(user-error "No more results"))
(if query
(ement-directory-search query :server server :session session :limit limit :since next-batch)
(ement-directory :server server :session session :limit limit :since next-batch))))
(defun ement-directory-mouse-1 (event)
"Call `ement-directory-RET' at EVENT."
(interactive "e")
(mouse-set-point event)
(call-interactively #'ement-directory-RET))
(defun ement-directory-RET ()
"View or join room at point, or cycle section at point."
(interactive)
(cl-etypecase (oref (magit-current-section) value)
(null nil)
(list (pcase-let* (((map ('name name) ('room_id room-id)) (oref (magit-current-section) value))
((map session) ement-directory-etc)
(room (cl-find room-id (ement-session-rooms session)
:key #'ement-room-id :test #'equal)))
(if room
(ement-view-room room session)
;; Room not joined: prompt to join. (Don't use the alias in the prompt,
;; because multiple rooms might have the same alias, e.g. when one is
;; upgraded or tombstoned.)
(when (yes-or-no-p (format "Join room \"%s\" <%s>? " name room-id))
(ement-join-room room-id session)))))
(taxy-magit-section (call-interactively #'magit-section-cycle))))
;;;; Functions
(cl-defun ement-directory--view (rooms &key init-fn append-p
(buffer-name "*Ement Directory*")
(root-section-name "Ement Directory")
(keys ement-directory-default-keys)
(display-buffer-action '(display-buffer-same-window)))
"View ROOMS in an `ement-directory-mode' buffer.
ROOMS should be a list of rooms from an API request. Calls
INIT-FN immediately after activating major mode. Sets
BUFFER-NAME and ROOT-SECTION-NAME, and uses
DISPLAY-BUFFER-ACTION. KEYS are a list of `taxy' keys. If
APPEND-P, add ROOMS to buffer rather than replacing existing
contents. To be called by `ement-directory-search'."
(declare (indent defun))
(let (column-sizes window-start)
(cl-labels ((format-item
;; NOTE: We use the buffer-local variable `ement-directory-etc' rather
;; than a closure variable because the taxy-magit-section struct's format
;; table is not stored in it, and we can't reuse closures' variables.
;; (It would be good to store the format table in the taxy-magit-section
;; in the future, to make this cleaner.)
(item) (gethash item (alist-get 'format-table ement-directory-etc)))
;; NOTE: Since these functions take an "item" (which is a [room session]
;; vector), they're prefixed "item-" rather than "room-".
(size
(item) (pcase-let (((map ('num_joined_members size)) item))
size))
(t<nil (a b) (and a (not b)))
(t>nil (a b) (and (not a) b))
(make-fn (&rest args)
(apply #'make-taxy-magit-section
:make #'make-fn
:format-fn #'format-item
;; FIXME: Should we reuse `ement-room-list-level-indent' here?
:level-indent ement-room-list-level-indent
;; :visibility-fn #'visible-p
;; :heading-indent 2
:item-indent 2
;; :heading-face-fn #'heading-face
args)))
(with-current-buffer (get-buffer-create buffer-name)
(unless (eq 'ement-directory-mode major-mode)
;; Don't obliterate buffer-local variables.
(ement-directory-mode))
(when init-fn
(funcall init-fn))
(pcase-let* ((taxy (if append-p
(alist-get 'taxy ement-directory-etc)
(make-fn
:name root-section-name
:take (taxy-make-take-function keys ement-directory-keys))))
(taxy-magit-section-insert-indent-items nil)
(inhibit-read-only t)
(pos (point))
(section-ident (when (magit-current-section)
(magit-section-ident (magit-current-section))))
(format-cons))
(setf taxy (thread-last taxy
(taxy-fill (cl-coerce rooms 'list))
(taxy-sort #'> #'size)
(taxy-sort* #'string> #'taxy-name))
(alist-get 'taxy ement-directory-etc) taxy
format-cons (taxy-magit-section-format-items
ement-directory-columns ement-directory-column-formatters taxy)
(alist-get 'format-table ement-directory-etc) (car format-cons)
column-sizes (cdr format-cons)
header-line-format (taxy-magit-section-format-header
column-sizes ement-directory-column-formatters)
window-start (if (get-buffer-window buffer-name)
(window-start (get-buffer-window buffer-name))
0))
(delete-all-overlays)
(erase-buffer)
(save-excursion
(taxy-magit-section-insert taxy :items 'first
;; :blank-between-depth bufler-taxy-blank-between-depth
:initial-depth 0))
(goto-char pos)
(when (and section-ident (magit-get-section section-ident))
(goto-char (oref (magit-get-section section-ident) start)))))
(display-buffer buffer-name display-buffer-action)
(when (get-buffer-window buffer-name)
(set-window-start (get-buffer-window buffer-name) window-start))
;; NOTE: In order for `bookmark--jump-via' to work properly, the restored buffer
;; must be set as the current buffer, so we have to do this explicitly here.
(set-buffer buffer-name))))
;;;; Spaces
;; Viewing spaces and the rooms in them.
;;;###autoload
(defun ement-view-space (space session)
;; TODO: Use this for spaces instead of `ement-view-room' (or something like that).
;; TODO: Display space's topic in the header or something.
"View child rooms in SPACE on SESSION.
SPACE may be a room ID or an `ement-room' struct."
;; TODO: "from" query parameter.
(interactive (ement-complete-room :predicate #'ement--room-space-p
:prompt "Space: "))
(pcase-let* ((id (cl-typecase space
(string space)
(ement-room (ement-room-id space))))
(endpoint (format "rooms/%s/hierarchy" id))
(revert-function (lambda (&rest _ignore)
(interactive)
(ement-view-space space session))))
(ement-api session endpoint :version "v1"
:then (lambda (results)
(pcase-let (((map rooms ('next_batch next-batch))
results))
(ement-directory--view rooms ;; :append-p since
;; TODO: Use space's alias where possible.
:buffer-name (format "*Ement Directory: space \"%s\"" id)
:root-section-name (format "*Ement Directory: space \"%s\"" id)
:init-fn (lambda ()
(setf (alist-get 'session ement-directory-etc) session
(alist-get 'next-batch ement-directory-etc) next-batch
;; (alist-get 'limit ement-directory-etc) limit
(alist-get 'space ement-directory-etc) space)
(setq-local revert-buffer-function revert-function)
;; TODO: Handle next batches.
;; (when remaining
;; (message
;; (substitute-command-keys
;; "%s rooms remaining (use \\[ement-directory-next] to fetch more)")
;; remaining))
)))))))
;;;; Footer
(provide 'ement-directory)
;;; ement-directory.el ends here
;;; ement-autoloads.el --- automatically extracted autoloads -*- lexical-binding: t -*-
;;
;;; Code:
(add-to-list 'load-path (directory-file-name
(or (file-name-directory #$) (car load-path))))
;;;### (autoloads nil "ement" "ement.el" (0 0 0 0))
;;; Generated autoloads from ement.el
(autoload 'ement-connect "ement" "\
Connect to Matrix with USER-ID and PASSWORD, or using SESSION.
Interactively, with prefix, ignore a saved session and log in
again; otherwise, use a saved session if `ement-save-sessions' is
enabled and a saved session is available, or prompt to log in if
not enabled or available.
If USERID or PASSWORD are not specified, the user will be
prompted for them.
If URI-PREFIX is specified, it should be the prefix of the
server's API URI, including protocol, hostname, and optionally
the port, e.g.
\"https://matrix-client.matrix.org\"
\"http://localhost:8080\"
\(fn &key USER-ID PASSWORD URI-PREFIX SESSION)" t nil)
(register-definition-prefixes "ement" '("ement-"))
;;;***
;;;### (autoloads nil "ement-api" "ement-api.el" (0 0 0 0))
;;; Generated autoloads from ement-api.el
(register-definition-prefixes "ement-api" '("ement-api-error"))
;;;***
;;;### (autoloads nil "ement-directory" "ement-directory.el" (0 0
;;;;;; 0 0))
;;; Generated autoloads from ement-directory.el
(autoload 'ement-directory "ement-directory" "\
View the public room directory on SERVER with SESSION.
Show up to LIMIT rooms. Interactively, with prefix, prompt for
server and LIMIT.
SINCE may be a next-batch token.
\(fn &key SERVER SESSION SINCE (LIMIT 100))" t nil)
(autoload 'ement-directory-search "ement-directory" "\
View public rooms on SERVER matching QUERY.
QUERY is a string used to filter results.
\(fn QUERY &key SERVER SESSION SINCE (LIMIT 1000))" t nil)
(autoload 'ement-view-space "ement-directory" "\
View child rooms in SPACE on SESSION.
SPACE may be a room ID or an `ement-room' struct.
\(fn SPACE SESSION)" t nil)
(register-definition-prefixes "ement-directory" '("ement-directory-"))
;;;***
;;;### (autoloads nil "ement-lib" "ement-lib.el" (0 0 0 0))
;;; Generated autoloads from ement-lib.el
(register-definition-prefixes "ement-lib" '("ement-"))
;;;***
;;;### (autoloads nil "ement-macros" "ement-macros.el" (0 0 0 0))
;;; Generated autoloads from ement-macros.el
(register-definition-prefixes "ement-macros" '("ement-"))
;;;***
;;;### (autoloads nil "ement-notify" "ement-notify.el" (0 0 0 0))
;;; Generated autoloads from ement-notify.el
(register-definition-prefixes "ement-notify" '("ement-notify"))
;;;***
;;;### (autoloads nil "ement-room" "ement-room.el" (0 0 0 0))
;;; Generated autoloads from ement-room.el
(register-definition-prefixes "ement-room" '("ement-"))
;;;***
;;;### (autoloads nil "ement-room-list" "ement-room-list.el" (0 0
;;;;;; 0 0))
;;; Generated autoloads from ement-room-list.el
(autoload 'ement-room-list--after-initial-sync "ement-room-list" "\
Call `ement-room-list', ignoring arguments.
To be called from `ement-after-initial-sync-hook'.
\(fn &rest IGNORE)" nil nil)
(defalias 'ement-list-rooms 'ement-room-list)
(autoload 'ement-room-list "ement-room-list" "\
Show a buffer listing Ement rooms, grouped with Taxy KEYS.
After showing it, its window is selected. The buffer is named
BUFFER-NAME and is shown with DISPLAY-BUFFER-ACTION; or if
DISPLAY-BUFFER-ACTION is nil, the buffer is not displayed.
\(fn &key (BUFFER-NAME \"*Ement Room List*\") (KEYS ement-room-list-default-keys) (DISPLAY-BUFFER-ACTION \\='((display-buffer-reuse-window display-buffer-same-window))))" t nil)
(autoload 'ement-room-list-auto-update "ement-room-list" "\
Automatically update the Taxy room list buffer.
+Does so when variable `ement-room-list-auto-update' is non-nil.
+To be called in `ement-sync-callback-hook'.
\(fn SESSION)" nil nil)
(register-definition-prefixes "ement-room-list" '("ement-room-list-"))
;;;***
;;;### (autoloads nil "ement-tabulated-room-list" "ement-tabulated-room-list.el"
;;;;;; (0 0 0 0))
;;; Generated autoloads from ement-tabulated-room-list.el
(autoload 'ement-tabulated-room-list "ement-tabulated-room-list" "\
Show buffer listing joined rooms.
Calls `pop-to-buffer-same-window'. Interactively, with prefix,
call `pop-to-buffer'.
\(fn &rest IGNORE)" t nil)
(autoload 'ement-tabulated-room-list-auto-update "ement-tabulated-room-list" "\
Automatically update the room list buffer.
Does so when variable `ement-tabulated-room-list-auto-update' is non-nil.
To be called in `ement-sync-callback-hook'.
\(fn SESSION)" nil nil)
(register-definition-prefixes "ement-tabulated-room-list" '("ement-tabulated-room-list-"))
;;;***
;;;### (autoloads nil nil ("ement-pkg.el" "ement-structs.el") (0
;;;;;; 0 0 0))
;;;***
;; Local Variables:
;; version-control: never
;; no-byte-compile: t
;; no-update-autoloads: t
;; coding: utf-8
;; End:
;;; ement-autoloads.el ends here
;;; ement-api.el --- Matrix API library -*- lexical-binding: t; -*-
;; Copyright (C) 2022 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Maintainer: Adam Porter <adam@alphapapa.net>
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;;
;;; Code:
;;;; Debugging
;; NOTE: Uncomment this form and `emacs-lisp-byte-compile-and-load' the file to enable
;; `ement-debug' messages. This is commented out by default because, even though the
;; messages are only displayed when `warning-minimum-log-level' is `:debug' at runtime, if
;; that is so at expansion time, the expanded macro calls format the message and check the
;; log level at runtime, which is not zero-cost.
;; (eval-and-compile
;; (setq-local warning-minimum-log-level nil)
;; (setq-local warning-minimum-log-level :debug))
;;;; Requirements
(require 'json)
(require 'url-parse)
(require 'url-util)
(require 'plz)
(require 'ement-macros)
(require 'ement-structs)
;;;; Variables
;;;; Customization
;;;; Commands
;;;; Functions
(cl-defun ement-api (session endpoint
&key then data params queue
(content-type "application/json")
(data-type 'text)
(else #'ement-api-error) (method 'get)
;; FIXME: What's the right term for the URL part after "/_matrix/"?
(endpoint-category "client")
(json-read-fn #'json-read)
;; NOTE: Hard to say what the default timeouts
;; should be. Sometimes the matrix.org homeserver
;; can get slow and respond a minute or two later.
(connect-timeout 10) (timeout 60)
(version "r0"))
"Make API request on SESSION to ENDPOINT.
The request automatically uses SESSION's server, URI prefix, and
access token.
These keyword arguments are passed to `plz', which see: THEN,
DATA (passed as BODY), QUEUE (passed to `plz-queue', which see),
DATA-TYPE (passed as BODY-TYPE), ELSE, METHOD,
JSON-READ-FN (passed as AS), CONNECT-TIMEOUT, TIMEOUT.
Other arguments include PARAMS (used as the URL's query
parameters), ENDPOINT-CATEGORY (added to the endpoint URL), and
VERSION (added to the endpoint URL).
Note that most Matrix requests expect JSON-encoded data, so
usually the DATA argument should be passed through
`json-encode'."
(declare (indent defun))
(pcase-let* (((cl-struct ement-session server token) session)
((cl-struct ement-server uri-prefix) server)
((cl-struct url type host portspec) (url-generic-parse-url uri-prefix))
(path (format "/_matrix/%s/%s/%s" endpoint-category version endpoint))
(query (url-build-query-string params))
(filename (concat path "?" query))
(url (url-recreate-url
(url-parse-make-urlobj type nil nil host portspec filename nil data t)))
(headers (ement-alist "Content-Type" content-type))
(plz-args))
(when token
;; Almost every request will require a token (only a few, like checking login flows, don't),
;; so we simplify the API by using the token automatically when the session has one.
(push (cons "Authorization" (concat "Bearer " token)) headers))
(setf plz-args (list method url :headers headers :body data :body-type data-type
:as json-read-fn :then then :else else
:connect-timeout connect-timeout :timeout timeout :noquery t))
;; Omit `then' from debugging because if it's a partially applied
;; function on the session object, which may be very large, it
;; will take a very long time to print into the warnings buffer.
;; (ement-debug (current-time) method url headers)
(if queue
(plz-run
(apply #'plz-queue queue plz-args))
(apply #'plz plz-args))))
(define-error 'ement-api-error "Ement API error" 'error)
(defun ement-api-error (plz-error)
"Signal an Ement API error for PLZ-ERROR."
;; This feels a little messy, but it seems to be reasonable.
(pcase-let* (((cl-struct plz-error response
(message plz-message) (curl-error `(,curl-exit-code . ,curl-message)))
plz-error)
(status (when (plz-response-p response)
(plz-response-status response)))
(body (when (plz-response-p response)
(plz-response-body response)))
(json-object (when body
(ignore-errors
(json-read-from-string body))))
(error-message (format "%S: %s"
(or curl-exit-code status)
(or (when json-object
(alist-get 'error json-object))
curl-message
plz-message))))
(signal 'ement-api-error (list error-message))))
;;;; Footer
(provide 'ement-api)
;;; ement-api.el ends here
This is the file .../info/dir, which contains the
topmost node of the Info hierarchy, called (dir)Top.
The first time you invoke Info you start off looking at this node.
File: dir, Node: Top This is the top of the INFO tree
This (the Directory node) gives a menu of major topics.
Typing "q" exits, "H" lists all Info commands, "d" returns here,
"h" gives a primer for first-timers,
"mEmacs<Return>" visits the Emacs manual, etc.
In Emacs, you can click mouse button 2 on a menu item or cross reference
to select it.
* Menu:
Emacs
* Ement: (ement). Matrix client for Emacs.
#+TITLE: Ement.el
#+PROPERTY: LOGGING nil
# Export options.
#+OPTIONS: broken-links:t *:t
# Info export options.
#+EXPORT_FILE_NAME: ement.texi
#+TEXINFO_DIR_CATEGORY: Emacs
#+TEXINFO_DIR_TITLE: Ement: (ement)
#+TEXINFO_DIR_DESC: Matrix client for Emacs
# Note: This readme works with the org-make-toc <https://github.com/alphapapa/org-make-toc> package, which automatically updates the table of contents.
#+HTML: <img src="images/logo-128px.png" align="right">
# ELPA badge image.
[[https://elpa.gnu.org/packages/ement.html][https://elpa.gnu.org/packages/ement.svg]]
Ement.el is a Matrix client for Emacs. It aims to be simple, fast, featureful, and reliable.
Feel free to join us in the chat room: [[https://matrix.to/#/#ement.el:matrix.org][https://img.shields.io/matrix/ement.el:matrix.org.svg?label=%23ement.el:matrix.org]]
* Contents :noexport:
:PROPERTIES:
:TOC: :include siblings
:END:
:CONTENTS:
- [[#installation][Installation]]
- [[#usage][Usage]]
- [[#bindings][Bindings]]
- [[#tips][Tips]]
- [[#encrypted-room-support-through-pantalaimon][Encrypted room support through Pantalaimon]]
- [[#rationale][Rationale]]
- [[#changelog][Changelog]]
- [[#development][Development]]
:END:
* Screenshots :noexport:
:PROPERTIES:
:ID: d818f690-5f22-4eb0-83e1-4d8ce16c9e5b
:END:
The default formatting style resembles IRC clients, with each message being prefixed by the username (which enables powerful Emacs features, like using Occur to show all messages from or mentioning a user). Alternative, built-in styles include an Element-like one with usernames above groups of messages, as well as a classic, no-margins IRC style. Messages may be optionally displayed with unique colors for each user (with customizeable contrast), making it easier to follow conversations. Timestamp headers are optionally displayed where a certain amount of time passes between events, as well as where the date changes.
[[images/ement-for-twim.png]]
/Two rooms shown in side-by-side buffers, showing inline images, reactions, date/time headings, room avatars, and messages colored by user (using the modus-vivendi Emacs theme)./
[[images/emacs-with-fully-read-line.png]]
/#emacs:libera.chat showing colored text from IRC users, replies with quoted parts, messages colored by user, addressed usernames colored by their user color, highlighted mentions, and the fully-read marker line (using the modus-vivendi Emacs theme)./
[[images/screenshot5.png]]
/Four rooms shown at once, with messages colored by user, in the default Emacs theme./
[[images/screenshot2.png]]
/A room at the top in the "Elemental" display style, with sender names displayed over groups of messages, and only self-messages in an alternate color. The lower window shows an earlier version of the rooms list./
[[images/reactions.png]]
/Reactions displayed as color emojis (may need [[#displaying-symbols-and-emojis][proper Emacs configuration]])./
* Installation
:PROPERTIES:
:TOC: :depth 0
:END:
** GNU ELPA
Ement.el is published in [[http://elpa.gnu.org/][GNU ELPA]], so it may be installed in Emacs with the command ~M-x package-install RET ement RET~. This is the recommended way to install Ement.el, as it will install the current stable release.
** GNU Guix
Ement.el is also available in [[https://guix.gnu.org/][GNU Guix]] as ~emacs-ement~.
** Debian
Ement.el is also available in Debian as [[https://packages.debian.org/elpa-ement][elpa-ement]].
** Git master
The ~master~ branch of the Git repository is intended to be usable at all times; only minor bugs are expected to be found in it before a new stable release is made. To install from this, it is recommended to use [[https://github.com/quelpa/quelpa-use-package][quelpa-use-package]], like this:
#+BEGIN_SRC elisp
;; Install and load `quelpa-use-package'.
(package-install 'quelpa-use-package)
(require 'quelpa-use-package)
;; Install Ement.
(use-package ement
:quelpa (ement :fetcher github :repo "alphapapa/ement.el"))
#+END_SRC
One might also use systems like [[https://github.com/radian-software/straight.el][Straight]] (which is also used by [[https://github.com/doomemacs/doomemacs][DOOM]]) to install from Git, but the author cannot offer support for them.
** Manual
Ement.el is intended to be installed with Emacs's package system, which will ensure that the required autoloads are generated, etc. If you choose to install it manually, you're on your own.
* Usage
:PROPERTIES:
:TOC: :include descendants :depth 1
:END:
:CONTENTS:
- [[#bindings][Bindings]]
- [[#tips][Tips]]
- [[#encrypted-room-support-through-pantalaimon][Encrypted room support through Pantalaimon]]
:END:
1. Call command ~ement-connect~ to connect. Multiple sessions are supported, so you may call the command again to connect to another account.
2. Wait for initial sync to complete (which can take a few moments--initial sync JSON requests can be large).
3. Use these commands (room-related commands may be called with universal prefix to prompt for the room):
- ~ement-list-rooms~ to view the list of joined rooms.
- ~ement-view-room~ to view a room's buffer, selected with completion.
- ~ement-create-room~ to create a new room.
- ~ement-create-space~ to create a space.
- ~ement-invite-user~ to invite a user to a room.
- ~ement-join-room~ to join a room.
- ~ement-leave-room~ to leave a room.
- ~ement-forget-room~ to forget a room.
- ~ement-tag-room~ to toggle a tag on a room (including favorite/low-priority status).
- ~ement-list-members~ to list members in a room.
- ~ement-send-direct-message~ to send a direct message to a user (in an existing direct room, or creating a new one automatically).
- ~ement-room-edit-message~ to edit a message at point.
- ~ement-room-send-file~ to send a file.
- ~ement-room-send-image~ to send an image.
- ~ement-room-set-topic~ to set a room's topic.
- ~ement-room-occur~ to search in a room's known events.
- ~ement-room-override-name~ to override a room's display name.
- ~ement-ignore-user~ to ignore a user (or with interactive prefix, un-ignore).
- ~ement-room-set-message-format~ to set a room's message format buffer-locally.
- ~ement-room-toggle-space~ to toggle a room's membership in a space (a way to group rooms in Matrix).
- ~ement-directory~ to view a room directory.
- ~ement-directory-search~ to search a room directory.
4. Use these special buffers to see events from multiple rooms (you can also reply to messages from these buffers!):
- See all new events that mention you in the =*Ement Mentions*= buffer.
- See all new events in rooms that have open buffers in the =*Ement Notifications*= buffer.
** Bindings
These bindings are common to all of the following buffer types:
+ Switch to a room buffer: ~M-g M-r~
+ Switch to the room list buffer: ~M-g M-l~
+ Switch to the mentions buffer: ~M-g M-m~
+ Switch to the notifications buffer: ~M-g M-n~
*** Room buffers
+ Show command menu: ~?~
[[images/transient.png]]
*Movement*
+ Next event: ~TAB~
+ Previous event: ~<backtab>~
+ Scroll up and mark read: ~SPC~
+ Scroll down: ~S-SPC~
+ Jump to fully-read marker: ~M-SPC~
+ Load older messages: at top of buffer, scroll contents up (i.e. ~S-SPC~, ~M-v~ or ~mwheel-scroll~)
*Switching*
+ List rooms: ~M-g M-l~
+ Switch to other room: ~M-g M-r~
+ Switch to mentions buffer: ~M-g M-m~
+ Switch to notifications buffer: ~M-g M-n~
+ Quit window: ~q~
*Messages*
+ Write message: ~RET~
+ Write reply to event at point (when region is active, only quote marked text) : ~S-RET~
+ Compose message in buffer: ~M-RET~ (while writing in minibuffer: ~C-c ')~ (Use command ~ement-room-compose-org~ to activate Org mode in the compose buffer.)
+ Edit message: ~<insert>~
+ Delete message: ~C-k~
+ Send reaction to event at point, or send same reaction at point: ~s r~
+ Send emote: ~s e~
+ Send file: ~s f~
+ Send image: ~s i~
+ View event source: ~v~
+ Complete members and rooms at point: ~C-M-i~ (standard ~completion-at-point~ command). (Type an ~@~ prefix for a member mention, a ~#~ prefix for a room alias, or a ~!~ prefix for a room ID.)
*Images*
+ Toggle scale of image (between fit-to-window and thumbnail): ~mouse-1~
+ Show image in new buffer at full size: ~double-mouse-1~
*Users*
+ Send direct message: ~u RET~
+ Invite user: ~u i~
+ Ignore user: ~u I~
*Room*
+ Occur search in room: ~M-s o~
+ List members: ~r m~
+ Set topic: ~r t~
+ Set message format: ~r f~
+ Set notification rules: ~r n~
+ Override display name: ~r N~
+ Tag/untag room: ~r T~
*Room membership*
+ Create room: ~R c~
+ Join room: ~R j~
+ Leave room: ~R l~
+ Forget room: ~R F~
+ Toggle room's spaces: ~R s~
*Other*
+ Sync new messages (not necessary if auto sync is enabled; with prefix to force new sync): ~g~
*** Room list buffer
+ Show buffer of room at point: ~RET~
+ Show buffer of next unread room: ~SPC~
+ Move between room names: ~TAB~ / ~<backtab>~
+ Kill room's buffer: ~k~
+ Toggle room's membership in a space: ~s~
*** Directory buffers
+ View/join a room: ~RET~ / ~mouse-1~
+ Load next batch of rooms: ~+~
*** Mentions/notifications buffers
+ Move between events: ~TAB~ / ~<backtab>~
+ Go to event at point in its room buffer: ~RET~
+ Write reply to event at point (shows the event in its room while writing) : ~S-RET~
** Tips
# TODO: Show sending messages in Org format.
+ Desktop notifications are enabled by default for events that mention the local user. They can also be shown for all events in rooms with open buffers.
+ Send messages in Org mode format by customizing the option ~ement-room-send-message-filter~ (which enables Org format by default), or by calling ~ement-room-compose-org~ in a compose buffer (which enables it for a single message). Then Org-formatted messages are automatically converted and sent as HTML-formatted messages (with the Org syntax as the plain-text fallback). You can send syntax such as:
- Bold, italic, underline, strikethrough
- Links
- Tables
- Source blocks (including results with ~:exports both~)
- Footnotes (okay, that might be pushing it, but you can!)
- And, generally, anything that Org can export to HTML
+ Starting in the room list buffer, by pressing ~SPC~ repeatedly, you can cycle through and read all rooms with unread buffers. (If a room doesn't have a buffer, it will not be included.)
+ Room buffers and the room-list buffer can be bookmarked in Emacs, i.e. using =C-x r m=. This is especially useful with [[https://github.com/alphapapa/burly.el][Burly]]: you can arrange an Emacs frame with several room buffers displayed at once, use =burly-bookmark-windows= to bookmark the layout, and then you can restore that layout and all of the room buffers by opening the bookmark, rather than having to manually arrange them every time you start Emacs or change the window configuration.
+ Images and other files can be uploaded to rooms using drag-and-drop.
+ Mention members by typing a ~@~ followed by their displayname or Matrix ID. (Members' names and rooms' aliases/IDs may be completed with ~completion-at-point~ commands.)
+ You can customize settings in the ~ement~ group.
- *Note:* ~setq~ should not be used for certain options, because it will not call the associated setter function. Users who have an aversion to the customization system may experience problems.
*** Displaying symbols and emojis
Emacs may not display certain symbols and emojis well by default. Based on [[https://emacs.stackexchange.com/questions/62049/override-the-default-font-for-emoji-characters][this question and answer]], you may find that the simplest way to fix this is to install an appropriate font, like [[https://www.google.com/get/noto/#emoji-zsye][Noto Emoji]], and then use this Elisp code:
#+BEGIN_SRC elisp
(setf use-default-font-for-symbols nil)
(set-fontset-font t 'unicode "Noto Emoji" nil 'append)
#+END_SRC
** Encrypted room support through Pantalaimon
Ement.el doesn't support encrypted rooms natively, but it can be used transparently with the E2EE-aware reverse proxy daemon [[https://github.com/matrix-org/pantalaimon/][Pantalaimon]]. After configuring it according to its documentation, call ~ement-connect~ with the appropriate hostname and port, like:
#+BEGIN_SRC elisp
(ement-connect :uri-prefix "http://localhost:8009")
#+END_SRC
* Rationale
Why write a new Emacs Matrix client when there is already [[https://github.com/alphapapa/matrix-client.el][matrix-client.el]], by the same author, no less? A few reasons:
- ~matrix-client~ uses an older version of the Matrix spec, r0.3.0, with a few elements of r0.4.0 grafted in. Bringing it up to date with the current version of the spec, r0.6.1, would be more work than to begin with the current version. Ement.el targets r0.6.1 from the beginning.
- ~matrix-client~ does not use Matrix's lazy-loading feature (which was added to the specification later), so initial sync requests can take a long time for the server to process and can be large (sometimes tens of megabytes of JSON for the client to process!). Ement.el uses lazy-loading, which significantly improves performance.
- ~matrix-client~ automatically makes buffers for every room a user has joined, even if the user doesn't currently want to watch a room. Ement.el opens room buffers on-demand, improving performance by not having to insert events into buffers for rooms the user isn't watching.
- ~matrix-client~ was developed without the intention of publishing it to, e.g. MELPA or ELPA. It has several dependencies, and its code does not always install or compile cleanly due to macro-expansion issues (apparently depending on the user's Emacs config). Ement.el is designed to have minimal dependencies outside of Emacs (currently only one, ~plz~, which could be imported into the project), and every file is linted and compiles cleanly using [[https://github.com/alphapapa/makem.sh][makem.sh]].
- ~matrix-client~ uses EIEIO, probably unnecessarily, since few, if any, of the benefits of EIEIO are realized in it. Ement.el uses structs instead.
- ~matrix-client~ uses bespoke code for inserting messages into buffers, which works pretty well, but has a few minor bugs which are difficult to track down. Ement.el uses Emacs's built-in (and perhaps little-known) ~ewoc~ library, which makes it much simpler and more reliable to insert and update messages in buffers, and enables the development of advanced UI features more easily.
- ~matrix-client~ was, to a certain extent, designed to imitate other messaging apps. The result is, at least when used with the ~matrix-client-frame~ command, fairly pleasing to use, but isn't especially "Emacsy." Ement.el is intended to better fit into Emacs's paradigms.
- ~matrix-client~'s long name makes for long symbol names, which makes for tedious, verbose code. ~ement~ is easy to type and makes for concise, readable code.
- The author has learned much since writing ~matrix-client~ and hopes to write simpler, more readable, more maintainable code in Ement.el. It's hoped that this will enable others to contribute more easily.
Note that, while ~matrix-client~ remains usable, and probably will for some time to come, Ement.el has now surpassed it in every way. The only reason to choose ~matrix-client~ instead is if one is using an older version of Emacs that isn't supported by Ement.el.
* Changelog
:PROPERTIES:
:TOC: :depth 0
:END:
** 0.9.2
*Fixes*
+ Restore position in room list when refreshing.
+ Completion in minibuffer.
** 0.9.1
*Fixes*
+ Error in ~ement-room-list~ command upon initial sync.
** 0.9
*Additions*
+ Option ~ement-room-timestamp-header-align~ controls how timestamp headers are aligned in room buffers.
+ Option ~ement-room-view-hook~ runs functions when ~ement-room-view~ is called. (By default, it refreshes the room list buffer.)
+ In the room list, middle-clicking a room which has a buffer closes its buffer.
+ Basic support for video events. (Thanks to [[https://github.com/viiru-][Arto Jantunen]].)
*Changes*
+ Using new option ~ement-room-timestamp-header-align~, timestamp headers default to right-aligned. (With default settings, this keeps them near message timestamps and makes for a cleaner appearance.)
*Fixes*
+ Recognition of certain MXID or displayname forms in outgoing messages when linkifying (aka "pilling") them.
+ Unreadable room avatar images no longer cause errors. (Fixes [[https://github.com/alphapapa/ement.el/issues/147][#147]]. Thanks to [[https://github.com/jgarte][@jgarte]] for reporting.)
+ Don't error in ~ement-room-list~ when no rooms are joined. (Fixes [[https://github.com/alphapapa/ement.el/issues/123][#123]]. Thanks to [[https://github.com/Kabouik][@Kabouik]] and [[https://github.com/oantolin][Omar Antolín Camarena]] for reporting.)
+ Enable member/room completion in compose buffers. (Fixes [[https://github.com/alphapapa/ement.el/issues/115][#115]]. Thanks to Thanks to [[https://github.com/piater][Justus Piater]] and [[https://github.com/chasecaleb][Caleb Chase]] for reporting.)
** 0.8.3
*Fixes*
+ Avoid use of ~pcase~'s ~(map :KEYWORD)~ form. (This can cause a broken installation on older versions of Emacs that have an older version of the ~map~ library loaded, such as Emacs 27.2 included in Debian 11. Since there's no way to force Emacs to actually load the version of ~map~ required by this package before installing it (which would naturally happen upon restarting Emacs), we can only avoid using such forms while these versions of Emacs are widely used.)
** 0.8.2
*Fixes*
+ Deduplicate grouped membership events.
** 0.8.1
Added missing changelog entry (of course).
** 0.8
*Additions*
+ Command ~ement-create-space~ creates a new space.
+ Command ~ement-room-toggle-space~ toggles a room's membership in a space (a way to group rooms in Matrix).
+ Visibility of sections in the room list is saved across sessions.
+ Command ~ement-room-list-kill-buffer~ kills a room's buffer from the room list.
+ Set ~device_id~ and ~initial_device_display_name~ upon login (e.g. =Ement.el: username@hostname=). ([[https://github.com/alphapapa/ement.el/issues/134][#134]]. Thanks to [[https://github.com/viiru-][Arto Jantunen]] for reporting.)
*Changes*
+ Room-related commands may be called interactively with a universal prefix to prompt for the room/session (allowing to send events or change settings in rooms other than the current one).
+ Command ~ement-room-list~ reuses an existing window showing the room list when possible. ([[https://github.com/alphapapa/ement.el/issues/131][#131]]. Thanks to [[https://github.com/jeffbowman][Jeff Bowman]] for suggesting.)
+ Command ~ement-tag-room~ toggles tags (rather than adding by default and removing when called with a prefix).
+ Default room grouping now groups "spaced" rooms separately.
*Fixes*
+ Message format filter works properly when writing replies.
+ Improve insertion of sender name headers when using the "Elemental" message format.
+ Prompts in commands ~ement-leave-room~ and ~ement-forget-room~.
** 0.7
*Additions*
+ Command ~ement-room-override-name~ sets a local override for a room's display name. (Especially helpful for 1:1 rooms and bridged rooms. See [[https://github.com/matrix-org/matrix-spec-proposals/pull/3015#issuecomment-1451017296][MSC3015]].)
*Changes*
+ Improve display of room tombstones (displayed at top and bottom of buffer, and new room ID is linked to join).
+ Use descriptive prompts in ~ement-leave-room~ and ~ement-forget-room~ commands.
*Fixes*
+ Command ~ement-view-space~ when called from a room buffer. (Thanks to [[https://github.com/MagicRB][Richard Brežák]] for reporting.)
+ Don't call ~display-buffer~ when reverting room list buffer. (Fixes [[https://github.com/alphapapa/ement.el/issues/121][#121]]. Thanks to [[https://github.com/mekeor][mekeor]] for reporting.)
+ Retry sync for network timeouts. (Accidentally broken in v0.6.)
*Internal*
+ Function ~ement-put-account-data~ accepts ~:room~ argument to put on a room's account data.
** 0.6
*Additions*
+ Command ~ement-view-space~ to view a space's rooms in a directory buffer.
*Changes*
+ Improve ~ement-describe-room~ command (formatting, bindings).
*Fixes*
+ Retry sync for HTTP 502 "Bad Gateway" errors.
+ Formatting of unban events.
+ Update password authentication according to newer Matrix spec. (Fixes compatibility with Conduit servers. [[https://github.com/alphapapa/ement.el/issues/66][#66]]. Thanks to [[https://github.com/tpeacock19][Travis Peacock]], [[https://github.com/viiru-][Arto Jantunen]], and [[https://github.com/scd31][Stephen D]].)
+ Image scaling issues. (Thanks to [[https://github.com/vizs][Visuwesh]].)
** 0.5.2
*Fixes*
+ Apply ~ement-initial-sync-timeout~ properly (important for when the homeserver is slow to respond).
** 0.5.1
*Fixes*
+ Autoload ~ement-directory~ commands.
+ Faces in ~ement-directory~ listings.
** 0.5
*Additions*
+ Present "joined-and-left" and "rejoined-and-left" membership event pairs as such.
+ Process and show rooms' canonical alias events.
*Changes*
+ The [[https://github.com/alphapapa/taxy.el][taxy.el]]-based room list, with programmable, smart grouping, is now the default ~ement-room-list~. (The old, ~tabulated-list-mode~-based room list is available as ~ement-tabulated-room-list~.)
+ When selecting a room to view with completion, don't offer spaces.
+ When selecting a room with completion, empty aliases and topics are omitted instead of being displayed as nil.
*Fixes*
+ Use of send-message filter when replying.
+ Replies may be written in compose buffers.
** 0.4.1
*Fixes*
+ Don't show "curl process interrupted" message when updating a read marker's position again.
** 0.4
*Additions*
+ Option ~ement-room-unread-only-counts-notifications~, now enabled by default, causes rooms' unread status to be determined only by their notification counts (which are set by the server and depend on rooms' notification settings).
+ Command ~ement-room-set-notification-state~ sets a room's notification state (imitating Element's user-friendly presets).
+ Room buffers' Transient menus show the room's notification state (imitating Element's user-friendly presets).
+ Command ~ement-set-display-name~ sets the user's global displayname.
+ Command ~ement-room-set-display-name~ sets the user's displayname in a room (which is also now displayed in the room's Transient menu).
+ Column ~Notifications~ in the ~ement-taxy-room-list~ buffer shows rooms' notification state.
+ Option ~ement-interrupted-sync-hook~ allows customization of how sync interruptions are handled. (Now, by default, a warning is displayed instead of merely a message.)
*Changes*
+ When a room's read receipt is updated, the room's buffer is also marked as unmodified. (In concert with the new option, this makes rooms' unread status more intuitive.)
*Fixes*
+ Binding of command ~ement-forget-room~ in room buffers.
+ Highlighting of ~@room~ mentions.
** 0.3.1
*Fixes*
+ Room unread status (when the last event in a room is sent by the local user, the room is considered read).
** 0.3
*Additions*
+ Command ~ement-directory~ shows a server's room directory.
+ Command ~ement-directory-search~ searches a server's room directory.
+ Command ~ement-directory-next~ fetches the next batch of rooms in a directory.
+ Command ~ement-leave-room~ accepts a ~FORCE-P~ argument (interactively, with prefix) to leave a room without prompting.
+ Command ~ement-forget-room~ accepts a ~FORCE-P~ argument (interactively, with prefix) to also leave the room, and to forget it without prompting.
+ Option ~ement-notify-mark-frame-urgent-predicates~ marks the frame as urgent when (by default) a message mentions the local user or "@room" and the message's room has an open buffer.
*Changes*
+ Minor improvements to date/time headers.
*Fixes*
+ Command ~ement-describe-room~ for rooms without topics.
+ Improve insertion of old messages around existing timestamp headers.
+ Reduce D-Bus notification system check timeout to 2 seconds (from the default of 25).
+ Compatibility with Emacs 27.
** 0.2.1
*Fixes*
+ Info manual export filename.
** 0.2
*Changes*
+ Read receipts are re-enabled. (They're now implemented with a global idle timer rather than ~window-scroll-functions~, which sometimes caused a strange race condition that could cause Emacs to become unresponsive or crash.)
+ When determining whether a room is considered unread, non-message events like membership changes, reactions, etc. are ignored. This fixes a bug that caused certain rooms that had no message events (like some bridged rooms) to appear as unread when they shouldn't have. But it's unclear whether this is always preferable (e.g. one might want a member leaving a room to cause it to be marked unread), so this is classified as a change rather than simply a fix, and more improvements may be made to this in the future. (Fixes [[https://github.com/alphapapa/ement.el/issues/97][#97]]. Thanks to [[https://github.com/MrRoy][Julien Roy]] for reporting and testing.)
+ The ~ement-taxy-room-list~ view no longer automatically refreshes the list if the region is active in the buffer. (This allows the user to operate on multiple rooms without the contents of the buffer changing before completing the process.)
*Fixes*
+ Links to only rooms (as opposed to links to events in rooms) may be activated to join them.
+ Read receipts mark the last completely visible event (rather than one that's only partially displayed).
+ Prevent error when a room avatar image fails to load.
** 0.1.4
*Fixed*
+ Info manual directory headers.
** 0.1.3
*Fixed*
# + Read receipt-sending function was called too many times when scrolling.
# + Send read receipts even when the last receipt is outside the range of retrieved events.
+ Temporarily disable sending of read receipts due to an unusual bug that could cause Emacs to become unresponsive. (The feature will be re-enabled in a future release.)
** 0.1.2
*Fixed*
+ Function ~ement-room-sync~ correctly updates room-list buffers. (Thanks to [[https://github.com/vizs][Visuwesh]].)
+ Only send D-Bus notifications when supported. (Fixes [[https://github.com/alphapapa/ement.el/issues/83][#83]]. Thanks to [[https://github.com/tsdh][Tassilo Horn]].)
** 0.1.1
*Fixed*
+ Function ~ement-room-scroll-up-mark-read~ selects the correct room window.
+ Option ~ement-room-list-avatars~ defaults to what function ~display-images-p~ returns.
** 0.1
After almost two years of development, the first tagged release. Submitted to GNU ELPA.
* Development
:PROPERTIES:
:TOC: :include this :ignore descendants
:END:
Bug reports, feature requests, suggestions — /oh my/!
** Copyright Assignment
:PROPERTIES:
:TOC: :ignore (this)
:END:
Ement.el is published in GNU ELPA and is considered part of GNU Emacs. Therefore, cumulative contributions of more than 15 lines of code require that the author assign copyright of such contributions to the FSF. Authors who are interested in doing so may contact [[mailto:assign@gnu.org][assign@gnu.org]] to request the appropriate form.
** Matrix spec in Org format
:PROPERTIES:
:TOC: :ignore (this)
:END:
An Org-formatted version of the Matrix spec is available in the [[https://github.com/alphapapa/ement.el/tree/meta/spec][meta/spec]] branch.
* License
:PROPERTIES:
:TOC: :ignore (this)
:END:
GPLv3
* COMMENT Config :noexport:
:PROPERTIES:
:TOC: :ignore (this descendants)
:END:
# NOTE: The #+OPTIONS: and other keywords did not take effect when in this section (perhaps due to file size or to changes in Org), so they were moved to the top of the file.
** File-local variables
# Local Variables:
# eval: (require 'org-make-toc)
# before-save-hook: org-make-toc
# org-export-with-properties: ()
# org-export-with-title: t
# End:
━━━━━━━━━━
EMENT.EL
━━━━━━━━━━
[https://elpa.gnu.org/packages/ement.svg]
Ement.el is a Matrix client for Emacs. It aims to be simple, fast,
featureful, and reliable.
Feel free to join us in the chat room:
[https://img.shields.io/matrix/ement.el:matrix.org.svg?label=%23ement.el:matrix.org]
[https://elpa.gnu.org/packages/ement.svg]
<https://elpa.gnu.org/packages/ement.html>
[https://img.shields.io/matrix/ement.el:matrix.org.svg?label=%23ement.el:matrix.org]
<https://matrix.to/#/#ement.el:matrix.org>
1 Installation
══════════════
1.1 GNU ELPA
────────────
Ement.el is published in [GNU ELPA], so it may be installed in Emacs
with the command `M-x package-install RET ement RET'. This is the
recommended way to install Ement.el, as it will install the current
stable release.
[GNU ELPA] <http://elpa.gnu.org/>
1.2 GNU Guix
────────────
Ement.el is also available in [GNU Guix] as `emacs-ement'.
[GNU Guix] <https://guix.gnu.org/>
1.3 Debian
──────────
Ement.el is also available in Debian as [elpa-ement].
[elpa-ement] <https://packages.debian.org/elpa-ement>
1.4 Git master
──────────────
The `master' branch of the Git repository is intended to be usable at
all times; only minor bugs are expected to be found in it before a new
stable release is made. To install from this, it is recommended to
use [quelpa-use-package], like this:
┌────
│ ;; Install and load `quelpa-use-package'.
│ (package-install 'quelpa-use-package)
│ (require 'quelpa-use-package)
│
│ ;; Install Ement.
│ (use-package ement
│ :quelpa (ement :fetcher github :repo "alphapapa/ement.el"))
└────
One might also use systems like [Straight] (which is also used by
[DOOM]) to install from Git, but the author cannot offer support for
them.
[quelpa-use-package] <https://github.com/quelpa/quelpa-use-package>
[Straight] <https://github.com/radian-software/straight.el>
[DOOM] <https://github.com/doomemacs/doomemacs>
1.5 Manual
──────────
Ement.el is intended to be installed with Emacs's package system,
which will ensure that the required autoloads are generated, etc. If
you choose to install it manually, you're on your own.
2 Usage
═══════
•
•
•
1. Call command `ement-connect' to connect. Multiple sessions are
supported, so you may call the command again to connect to another
account.
2. Wait for initial sync to complete (which can take a few
moments–initial sync JSON requests can be large).
3. Use these commands (room-related commands may be called with
universal prefix to prompt for the room):
• `ement-list-rooms' to view the list of joined rooms.
• `ement-view-room' to view a room's buffer, selected with
completion.
• `ement-create-room' to create a new room.
• `ement-create-space' to create a space.
• `ement-invite-user' to invite a user to a room.
• `ement-join-room' to join a room.
• `ement-leave-room' to leave a room.
• `ement-forget-room' to forget a room.
• `ement-tag-room' to toggle a tag on a room (including
favorite/low-priority status).
• `ement-list-members' to list members in a room.
• `ement-send-direct-message' to send a direct message to a user
(in an existing direct room, or creating a new one
automatically).
• `ement-room-edit-message' to edit a message at point.
• `ement-room-send-file' to send a file.
• `ement-room-send-image' to send an image.
• `ement-room-set-topic' to set a room's topic.
• `ement-room-occur' to search in a room's known events.
• `ement-room-override-name' to override a room's display name.
• `ement-ignore-user' to ignore a user (or with interactive prefix,
un-ignore).
• `ement-room-set-message-format' to set a room's message format
buffer-locally.
• `ement-room-toggle-space' to toggle a room's membership in a
space (a way to group rooms in Matrix).
• `ement-directory' to view a room directory.
• `ement-directory-search' to search a room directory.
4. Use these special buffers to see events from multiple rooms (you
can also reply to messages from these buffers!):
• See all new events that mention you in the `*Ement Mentions*'
buffer.
• See all new events in rooms that have open buffers in the `*Ement
Notifications*' buffer.
2.1 Bindings
────────────
These bindings are common to all of the following buffer types:
⁃ Switch to a room buffer: `M-g M-r'
⁃ Switch to the room list buffer: `M-g M-l'
⁃ Switch to the mentions buffer: `M-g M-m'
⁃ Switch to the notifications buffer: `M-g M-n'
2.1.1 Room buffers
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
⁃ Show command menu: `?'
*Movement*
⁃ Next event: `TAB'
⁃ Previous event: `<backtab>'
⁃ Scroll up and mark read: `SPC'
⁃ Scroll down: `S-SPC'
⁃ Jump to fully-read marker: `M-SPC'
⁃ Load older messages: at top of buffer, scroll contents up
(i.e. `S-SPC', `M-v' or `mwheel-scroll')
*Switching*
⁃ List rooms: `M-g M-l'
⁃ Switch to other room: `M-g M-r'
⁃ Switch to mentions buffer: `M-g M-m'
⁃ Switch to notifications buffer: `M-g M-n'
⁃ Quit window: `q'
*Messages*
⁃ Write message: `RET'
⁃ Write reply to event at point (when region is active, only quote
marked text) : `S-RET'
⁃ Compose message in buffer: `M-RET' (while writing in minibuffer:
`C-c ')' (Use command `ement-room-compose-org' to activate Org mode
in the compose buffer.)
⁃ Edit message: `<insert>'
⁃ Delete message: `C-k'
⁃ Send reaction to event at point, or send same reaction at point: `s
r'
⁃ Send emote: `s e'
⁃ Send file: `s f'
⁃ Send image: `s i'
⁃ View event source: `v'
⁃ Complete members and rooms at point: `C-M-i' (standard
`completion-at-point' command). (Type an `@' prefix for a member
mention, a `#' prefix for a room alias, or a `!' prefix for a room
ID.)
*Images*
⁃ Toggle scale of image (between fit-to-window and thumbnail):
`mouse-1'
⁃ Show image in new buffer at full size: `double-mouse-1'
*Users*
⁃ Send direct message: `u RET'
⁃ Invite user: `u i'
⁃ Ignore user: `u I'
*Room*
⁃ Occur search in room: `M-s o'
⁃ List members: `r m'
⁃ Set topic: `r t'
⁃ Set message format: `r f'
⁃ Set notification rules: `r n'
⁃ Override display name: `r N'
⁃ Tag/untag room: `r T'
*Room membership*
⁃ Create room: `R c'
⁃ Join room: `R j'
⁃ Leave room: `R l'
⁃ Forget room: `R F'
⁃ Toggle room's spaces: `R s'
*Other*
⁃ Sync new messages (not necessary if auto sync is enabled; with
prefix to force new sync): `g'
2.1.2 Room list buffer
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
⁃ Show buffer of room at point: `RET'
⁃ Show buffer of next unread room: `SPC'
⁃ Move between room names: `TAB' / `<backtab>'
⁃ Kill room's buffer: `k'
⁃ Toggle room's membership in a space: `s'
2.1.3 Directory buffers
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
⁃ View/join a room: `RET' / `mouse-1'
⁃ Load next batch of rooms: `+'
2.1.4 Mentions/notifications buffers
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
⁃ Move between events: `TAB' / `<backtab>'
⁃ Go to event at point in its room buffer: `RET'
⁃ Write reply to event at point (shows the event in its room while
writing) : `S-RET'
2.2 Tips
────────
⁃ Desktop notifications are enabled by default for events that mention
the local user. They can also be shown for all events in rooms with
open buffers.
⁃ Send messages in Org mode format by customizing the option
`ement-room-send-message-filter' (which enables Org format by
default), or by calling `ement-room-compose-org' in a compose buffer
(which enables it for a single message). Then Org-formatted
messages are automatically converted and sent as HTML-formatted
messages (with the Org syntax as the plain-text fallback). You can
send syntax such as:
• Bold, italic, underline, strikethrough
• Links
• Tables
• Source blocks (including results with `:exports both')
• Footnotes (okay, that might be pushing it, but you can!)
• And, generally, anything that Org can export to HTML
⁃ Starting in the room list buffer, by pressing `SPC' repeatedly, you
can cycle through and read all rooms with unread buffers. (If a
room doesn't have a buffer, it will not be included.)
⁃ Room buffers and the room-list buffer can be bookmarked in Emacs,
i.e. using `C-x r m'. This is especially useful with [Burly]: you
can arrange an Emacs frame with several room buffers displayed at
once, use `burly-bookmark-windows' to bookmark the layout, and then
you can restore that layout and all of the room buffers by opening
the bookmark, rather than having to manually arrange them every time
you start Emacs or change the window configuration.
⁃ Images and other files can be uploaded to rooms using drag-and-drop.
⁃ Mention members by typing a `@' followed by their displayname or
Matrix ID. (Members' names and rooms' aliases/IDs may be completed
with `completion-at-point' commands.)
⁃ You can customize settings in the `ement' group.
• *Note:* `setq' should not be used for certain options, because it
will not call the associated setter function. Users who have an
aversion to the customization system may experience problems.
[Burly] <https://github.com/alphapapa/burly.el>
2.2.1 Displaying symbols and emojis
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
Emacs may not display certain symbols and emojis well by default.
Based on [this question and answer], you may find that the simplest
way to fix this is to install an appropriate font, like [Noto Emoji],
and then use this Elisp code:
┌────
│ (setf use-default-font-for-symbols nil)
│ (set-fontset-font t 'unicode "Noto Emoji" nil 'append)
└────
[this question and answer]
<https://emacs.stackexchange.com/questions/62049/override-the-default-font-for-emoji-characters>
[Noto Emoji] <https://www.google.com/get/noto/#emoji-zsye>
2.3 Encrypted room support through Pantalaimon
──────────────────────────────────────────────
Ement.el doesn't support encrypted rooms natively, but it can be used
transparently with the E2EE-aware reverse proxy daemon [Pantalaimon].
After configuring it according to its documentation, call
`ement-connect' with the appropriate hostname and port, like:
┌────
│ (ement-connect :uri-prefix "http://localhost:8009")
└────
[Pantalaimon] <https://github.com/matrix-org/pantalaimon/>
3 Rationale
═══════════
Why write a new Emacs Matrix client when there is already
[matrix-client.el], by the same author, no less? A few reasons:
• `matrix-client' uses an older version of the Matrix spec, r0.3.0,
with a few elements of r0.4.0 grafted in. Bringing it up to date
with the current version of the spec, r0.6.1, would be more work
than to begin with the current version. Ement.el targets r0.6.1
from the beginning.
• `matrix-client' does not use Matrix's lazy-loading feature (which
was added to the specification later), so initial sync requests can
take a long time for the server to process and can be large
(sometimes tens of megabytes of JSON for the client to process!).
Ement.el uses lazy-loading, which significantly improves
performance.
• `matrix-client' automatically makes buffers for every room a user
has joined, even if the user doesn't currently want to watch a room.
Ement.el opens room buffers on-demand, improving performance by not
having to insert events into buffers for rooms the user isn't
watching.
• `matrix-client' was developed without the intention of publishing it
to, e.g. MELPA or ELPA. It has several dependencies, and its code
does not always install or compile cleanly due to macro-expansion
issues (apparently depending on the user's Emacs config). Ement.el
is designed to have minimal dependencies outside of Emacs (currently
only one, `plz', which could be imported into the project), and
every file is linted and compiles cleanly using [makem.sh].
• `matrix-client' uses EIEIO, probably unnecessarily, since few, if
any, of the benefits of EIEIO are realized in it. Ement.el uses
structs instead.
• `matrix-client' uses bespoke code for inserting messages into
buffers, which works pretty well, but has a few minor bugs which are
difficult to track down. Ement.el uses Emacs's built-in (and
perhaps little-known) `ewoc' library, which makes it much simpler
and more reliable to insert and update messages in buffers, and
enables the development of advanced UI features more easily.
• `matrix-client' was, to a certain extent, designed to imitate other
messaging apps. The result is, at least when used with the
`matrix-client-frame' command, fairly pleasing to use, but isn't
especially "Emacsy." Ement.el is intended to better fit into
Emacs's paradigms.
• `matrix-client''s long name makes for long symbol names, which makes
for tedious, verbose code. `ement' is easy to type and makes for
concise, readable code.
• The author has learned much since writing `matrix-client' and hopes
to write simpler, more readable, more maintainable code in Ement.el.
It's hoped that this will enable others to contribute more easily.
Note that, while `matrix-client' remains usable, and probably will for
some time to come, Ement.el has now surpassed it in every way. The
only reason to choose `matrix-client' instead is if one is using an
older version of Emacs that isn't supported by Ement.el.
[matrix-client.el] <https://github.com/alphapapa/matrix-client.el>
[makem.sh] <https://github.com/alphapapa/makem.sh>
4 Changelog
═══════════
4.1 0.9.2
─────────
*Fixes*
⁃ Restore position in room list when refreshing.
⁃ Completion in minibuffer.
4.2 0.9.1
─────────
*Fixes*
⁃ Error in `ement-room-list' command upon initial sync.
4.3 0.9
───────
*Additions*
⁃ Option `ement-room-timestamp-header-align' controls how timestamp
headers are aligned in room buffers.
⁃ Option `ement-room-view-hook' runs functions when `ement-room-view'
is called. (By default, it refreshes the room list buffer.)
⁃ In the room list, middle-clicking a room which has a buffer closes
its buffer.
⁃ Basic support for video events. (Thanks to [Arto Jantunen].)
*Changes*
⁃ Using new option `ement-room-timestamp-header-align', timestamp
headers default to right-aligned. (With default settings, this
keeps them near message timestamps and makes for a cleaner
appearance.)
*Fixes*
⁃ Recognition of certain MXID or displayname forms in outgoing
messages when linkifying (aka "pilling") them.
⁃ Unreadable room avatar images no longer cause errors. (Fixes
[#147]. Thanks to [@jgarte] for reporting.)
⁃ Don't error in `ement-room-list' when no rooms are joined. (Fixes
[#123]. Thanks to [@Kabouik] and [Omar Antolín Camarena] for
reporting.)
⁃ Enable member/room completion in compose buffers. (Fixes [#115].
Thanks to Thanks to [Justus Piater] and [Caleb Chase] for
reporting.)
[Arto Jantunen] <https://github.com/viiru->
[#147] <https://github.com/alphapapa/ement.el/issues/147>
[@jgarte] <https://github.com/jgarte>
[#123] <https://github.com/alphapapa/ement.el/issues/123>
[@Kabouik] <https://github.com/Kabouik>
[Omar Antolín Camarena] <https://github.com/oantolin>
[#115] <https://github.com/alphapapa/ement.el/issues/115>
[Justus Piater] <https://github.com/piater>
[Caleb Chase] <https://github.com/chasecaleb>
4.4 0.8.3
─────────
*Fixes*
⁃ Avoid use of `pcase''s `(map :KEYWORD)' form. (This can cause a
broken installation on older versions of Emacs that have an older
version of the `map' library loaded, such as Emacs 27.2 included in
Debian 11. Since there's no way to force Emacs to actually load the
version of `map' required by this package before installing it
(which would naturally happen upon restarting Emacs), we can only
avoid using such forms while these versions of Emacs are widely
used.)
4.5 0.8.2
─────────
*Fixes*
⁃ Deduplicate grouped membership events.
4.6 0.8.1
─────────
Added missing changelog entry (of course).
4.7 0.8
───────
*Additions*
⁃ Command `ement-create-space' creates a new space.
⁃ Command `ement-room-toggle-space' toggles a room's membership in a
space (a way to group rooms in Matrix).
⁃ Visibility of sections in the room list is saved across sessions.
⁃ Command `ement-room-list-kill-buffer' kills a room's buffer from the
room list.
⁃ Set `device_id' and `initial_device_display_name' upon login
(e.g. `Ement.el: username@hostname'). ([#134]. Thanks to [Arto
Jantunen] for reporting.)
*Changes*
⁃ Room-related commands may be called interactively with a universal
prefix to prompt for the room/session (allowing to send events or
change settings in rooms other than the current one).
⁃ Command `ement-room-list' reuses an existing window showing the room
list when possible. ([#131]. Thanks to [Jeff Bowman] for
suggesting.)
⁃ Command `ement-tag-room' toggles tags (rather than adding by default
and removing when called with a prefix).
⁃ Default room grouping now groups "spaced" rooms separately.
*Fixes*
⁃ Message format filter works properly when writing replies.
⁃ Improve insertion of sender name headers when using the "Elemental"
message format.
⁃ Prompts in commands `ement-leave-room' and `ement-forget-room'.
[#134] <https://github.com/alphapapa/ement.el/issues/134>
[Arto Jantunen] <https://github.com/viiru->
[#131] <https://github.com/alphapapa/ement.el/issues/131>
[Jeff Bowman] <https://github.com/jeffbowman>
4.8 0.7
───────
*Additions*
⁃ Command `ement-room-override-name' sets a local override for a
room's display name. (Especially helpful for 1:1 rooms and bridged
rooms. See [MSC3015].)
*Changes*
⁃ Improve display of room tombstones (displayed at top and bottom of
buffer, and new room ID is linked to join).
⁃ Use descriptive prompts in `ement-leave-room' and
`ement-forget-room' commands.
*Fixes*
⁃ Command `ement-view-space' when called from a room buffer. (Thanks
to [Richard Brežák] for reporting.)
⁃ Don't call `display-buffer' when reverting room list buffer. (Fixes
[#121]. Thanks to [mekeor] for reporting.)
⁃ Retry sync for network timeouts. (Accidentally broken in v0.6.)
*Internal*
⁃ Function `ement-put-account-data' accepts `:room' argument to put on
a room's account data.
[MSC3015]
<https://github.com/matrix-org/matrix-spec-proposals/pull/3015#issuecomment-1451017296>
[Richard Brežák] <https://github.com/MagicRB>
[#121] <https://github.com/alphapapa/ement.el/issues/121>
[mekeor] <https://github.com/mekeor>
4.9 0.6
───────
*Additions*
⁃ Command `ement-view-space' to view a space's rooms in a directory
buffer.
*Changes*
⁃ Improve `ement-describe-room' command (formatting, bindings).
*Fixes*
⁃ Retry sync for HTTP 502 "Bad Gateway" errors.
⁃ Formatting of unban events.
⁃ Update password authentication according to newer Matrix spec.
(Fixes compatibility with Conduit servers. [#66]. Thanks to
[Travis Peacock], [Arto Jantunen], and [Stephen D].)
⁃ Image scaling issues. (Thanks to [Visuwesh].)
[#66] <https://github.com/alphapapa/ement.el/issues/66>
[Travis Peacock] <https://github.com/tpeacock19>
[Arto Jantunen] <https://github.com/viiru->
[Stephen D] <https://github.com/scd31>
[Visuwesh] <https://github.com/vizs>
4.10 0.5.2
──────────
*Fixes*
⁃ Apply `ement-initial-sync-timeout' properly (important for when the
homeserver is slow to respond).
4.11 0.5.1
──────────
*Fixes*
⁃ Autoload `ement-directory' commands.
⁃ Faces in `ement-directory' listings.
4.12 0.5
────────
*Additions*
⁃ Present "joined-and-left" and "rejoined-and-left" membership event
pairs as such.
⁃ Process and show rooms' canonical alias events.
*Changes*
⁃ The [taxy.el]-based room list, with programmable, smart grouping, is
now the default `ement-room-list'. (The old,
`tabulated-list-mode'-based room list is available as
`ement-tabulated-room-list'.)
⁃ When selecting a room to view with completion, don't offer spaces.
⁃ When selecting a room with completion, empty aliases and topics are
omitted instead of being displayed as nil.
*Fixes*
⁃ Use of send-message filter when replying.
⁃ Replies may be written in compose buffers.
[taxy.el] <https://github.com/alphapapa/taxy.el>
4.13 0.4.1
──────────
*Fixes*
⁃ Don't show "curl process interrupted" message when updating a read
marker's position again.
4.14 0.4
────────
*Additions*
⁃ Option `ement-room-unread-only-counts-notifications', now enabled by
default, causes rooms' unread status to be determined only by their
notification counts (which are set by the server and depend on
rooms' notification settings).
⁃ Command `ement-room-set-notification-state' sets a room's
notification state (imitating Element's user-friendly presets).
⁃ Room buffers' Transient menus show the room's notification state
(imitating Element's user-friendly presets).
⁃ Command `ement-set-display-name' sets the user's global displayname.
⁃ Command `ement-room-set-display-name' sets the user's displayname in
a room (which is also now displayed in the room's Transient menu).
⁃ Column `Notifications' in the `ement-taxy-room-list' buffer shows
rooms' notification state.
⁃ Option `ement-interrupted-sync-hook' allows customization of how
sync interruptions are handled. (Now, by default, a warning is
displayed instead of merely a message.)
*Changes*
⁃ When a room's read receipt is updated, the room's buffer is also
marked as unmodified. (In concert with the new option, this makes
rooms' unread status more intuitive.)
*Fixes*
⁃ Binding of command `ement-forget-room' in room buffers.
⁃ Highlighting of `@room' mentions.
4.15 0.3.1
──────────
*Fixes*
⁃ Room unread status (when the last event in a room is sent by the
local user, the room is considered read).
4.16 0.3
────────
*Additions*
⁃ Command `ement-directory' shows a server's room directory.
⁃ Command `ement-directory-search' searches a server's room directory.
⁃ Command `ement-directory-next' fetches the next batch of rooms in a
directory.
⁃ Command `ement-leave-room' accepts a `FORCE-P' argument
(interactively, with prefix) to leave a room without prompting.
⁃ Command `ement-forget-room' accepts a `FORCE-P' argument
(interactively, with prefix) to also leave the room, and to forget
it without prompting.
⁃ Option `ement-notify-mark-frame-urgent-predicates' marks the frame
as urgent when (by default) a message mentions the local user or
"@room" and the message's room has an open buffer.
*Changes*
⁃ Minor improvements to date/time headers.
*Fixes*
⁃ Command `ement-describe-room' for rooms without topics.
⁃ Improve insertion of old messages around existing timestamp headers.
⁃ Reduce D-Bus notification system check timeout to 2 seconds (from
the default of 25).
⁃ Compatibility with Emacs 27.
4.17 0.2.1
──────────
*Fixes*
⁃ Info manual export filename.
4.18 0.2
────────
*Changes*
⁃ Read receipts are re-enabled. (They're now implemented with a
global idle timer rather than `window-scroll-functions', which
sometimes caused a strange race condition that could cause Emacs to
become unresponsive or crash.)
⁃ When determining whether a room is considered unread, non-message
events like membership changes, reactions, etc. are ignored. This
fixes a bug that caused certain rooms that had no message events
(like some bridged rooms) to appear as unread when they shouldn't
have. But it's unclear whether this is always preferable (e.g. one
might want a member leaving a room to cause it to be marked unread),
so this is classified as a change rather than simply a fix, and more
improvements may be made to this in the future. (Fixes [#97].
Thanks to [Julien Roy] for reporting and testing.)
⁃ The `ement-taxy-room-list' view no longer automatically refreshes
the list if the region is active in the buffer. (This allows the
user to operate on multiple rooms without the contents of the buffer
changing before completing the process.)
*Fixes*
⁃ Links to only rooms (as opposed to links to events in rooms) may be
activated to join them.
⁃ Read receipts mark the last completely visible event (rather than
one that's only partially displayed).
⁃ Prevent error when a room avatar image fails to load.
[#97] <https://github.com/alphapapa/ement.el/issues/97>
[Julien Roy] <https://github.com/MrRoy>
4.19 0.1.4
──────────
*Fixed*
⁃ Info manual directory headers.
4.20 0.1.3
──────────
*Fixed*
⁃ Temporarily disable sending of read receipts due to an unusual bug
that could cause Emacs to become unresponsive. (The feature will be
re-enabled in a future release.)
4.21 0.1.2
──────────
*Fixed*
⁃ Function `ement-room-sync' correctly updates room-list buffers.
(Thanks to [Visuwesh].)
⁃ Only send D-Bus notifications when supported. (Fixes [#83]. Thanks
to [Tassilo Horn].)
[Visuwesh] <https://github.com/vizs>
[#83] <https://github.com/alphapapa/ement.el/issues/83>
[Tassilo Horn] <https://github.com/tsdh>
4.22 0.1.1
──────────
*Fixed*
⁃ Function `ement-room-scroll-up-mark-read' selects the correct room
window.
⁃ Option `ement-room-list-avatars' defaults to what function
`display-images-p' returns.
4.23 0.1
────────
After almost two years of development, the first tagged release.
Submitted to GNU ELPA.
5 Development
═════════════
Bug reports, feature requests, suggestions — /oh my/!
5.1 Copyright Assignment
────────────────────────
Ement.el is published in GNU ELPA and is considered part of GNU Emacs.
Therefore, cumulative contributions of more than 15 lines of code
require that the author assign copyright of such contributions to the
FSF. Authors who are interested in doing so may contact
[assign@gnu.org] to request the appropriate form.
[assign@gnu.org] <mailto:assign@gnu.org>
5.2 Matrix spec in Org format
─────────────────────────────
An Org-formatted version of the Matrix spec is available in the
[meta/spec] branch.
[meta/spec] <https://github.com/alphapapa/ement.el/tree/meta/spec>
6 License
═════════
GPLv3
# * test.yml --- Test Emacs packages using makem.sh on GitHub Actions
# URL: https://github.com/alphapapa/makem.sh
# Version: 0.4.2
# * Commentary:
# Based on Steve Purcell's examples at
# <https://github.com/purcell/setup-emacs/blob/master/.github/workflows/test.yml>,
# <https://github.com/purcell/package-lint/blob/master/.github/workflows/test.yml>.
# * License:
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# * Code:
name: "CI"
on:
pull_request:
push:
# Comment out this section to enable testing of all branches.
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
emacs_version:
- 26.3
- 27.1
- 28.2
- snapshot
steps:
- uses: purcell/setup-emacs@master
with:
version: ${{ matrix.emacs_version }}
- uses: actions/checkout@v2
- name: Initialize sandbox
run: |
SANDBOX_DIR=$(mktemp -d) || exit 1
echo "SANDBOX_DIR=$SANDBOX_DIR" >> $GITHUB_ENV
./makem.sh -vv --sandbox=$SANDBOX_DIR --install-deps --install-linters
# The "all" rule is not used, because it treats compilation warnings
# as failures, so linting and testing are run as separate steps.
- name: Lint
# NOTE: Uncomment this line to treat lint failures as passing
# so the job doesn't show failure.
# continue-on-error: true
run: ./makem.sh -vv --sandbox=$SANDBOX_DIR lint
- name: Test
if: always() # Run test even if linting fails.
run: ./makem.sh -vv --sandbox=$SANDBOX_DIR test
# Local Variables:
# eval: (outline-minor-mode)
# End:
.github/
images/
LICENSE
Makefile
makem.sh
NOTES.org
screenshots/
;;; Directory Local Variables
;;; For more information see (info "(emacs) Directory Variables")
((emacs-lisp-mode . ((fill-column . 90)
(indent-tabs-mode . nil))))
Good signature from 066DAFCB81E42C40 GNU ELPA Signing Agent (2019) <elpasign@elpa.gnu.org> (trust undefined) created at 2023-06-20T17:05:02-0400 using RSA
;;; emacs-gc-stats.el --- Collect Emacs GC statistics -*- lexical-binding: t; -*-
;; Copyright (C) 2023 Free Software Foundation, Inc.
;; Author: Ihor Radchenko <yantar92@posteo.net>
;; Maintainer: Ihor Radchenko <yantar92@posteo.net>
;; URL: https://git.sr.ht/~yantar92/emacs-gc-stats
;; Package-Requires: ((emacs "25.1"))
;; Version: 1.3
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;;
;; This package collects Emacs garbage statistics over time and saves
;; it in the format that can be shared with Emacs maintainers.
;;
;;; Usage:
;;
;; Add
;; (require 'emacs-gc-stats)
;; (emacs-gc-stats-mode +1)
;; to your init file to enable the statistics acquiring.
;;
;; When you are ready to share the results, run
;; M-x emacs-gc-stats-save-session
;; and then share the saved `emacs-gc-stats-file'.
;;
;; You can use `emacs-gc-stats-clear' to clear the currently collected
;; session data.
;;
;;; Code:
(defgroup emacs-gc-stats nil
"Collect and share Emacs GC statistics."
:tag "GC stats"
:group 'development)
(defcustom emacs-gc-stats-file (expand-file-name "emacs-gc-stats.eld" user-emacs-directory)
"File used to store the statistics across Emacs sessions."
:type 'file)
(defcustom emacs-gc-stats-gc-defaults nil
"GC strategy to be active in `emacs-gc-stats-mode'.
This setting, when non-nil, will override the existing values of
`gc-cons-threshold' and `gc-cons-percentage'."
:type '(choice
(const :tag "Do not change existing GC settings" nil)
(const :tag "Force emacs defaults" emacs-defaults)))
(defcustom emacs-gc-stats-inhibit-command-name-logging nil
"When non-nil, do not collect command names (`this-command')."
:type 'boolean
:package-version '(emacs-gc-stats . 1.3))
(defcustom emacs-gc-stats-remind nil
"When non-nil, remind about submitting the data to Emacs devs.
The value is wither nil (do not remind), t (remind in 3 weeks), or a
number of days."
:type '(choice
(const :tag "No reminder" nil)
(const :tag "Remind in 3 weeks" t)
(integer :tag "Remind after N days"))
:package-version '(emacs-gc-stats . 1.3))
(defcustom emacs-gc-stats-setting-vars
'(gc-cons-threshold
gc-cons-percentage
memory-limit
emacs-version
doom-version
spacemacs-version
prelude-tips
(memory-info)
(memory-use-counts))
"List of variable/function symbols to collect after loading init.el."
:type '(list sexp)
:package-version '(emacs-gc-stats . 1.3))
(defcustom emacs-gc-stats-command-vars
'(gc-cons-threshold
gc-cons-percentage
gcmh-mode
gc-elapsed
gcs-done
this-command
memory-limit
(memory-info)
(memory-use-counts)
emacs-gc-stats--idle-tic)
"List of variable/function symbols to collect for each GC or command."
:type '(list sexp)
:package-version '(emacs-gc-stats . 1.3))
(defcustom emacs-gc-stats-summary-vars
'(gc-cons-threshold
gc-cons-percentage
gc-elapsed
gcs-done
memory-limit
(memory-info)
emacs-uptime
(memory-use-counts)
emacs-gc-stats-idle-delay
emacs-gc-stats--idle-tic)
"List of variables to collect at session end."
:type '(list sexp)
:package-version '(emacs-gc-stats . 1.3))
(defun emacs-gc-stats--collect (&rest symbols)
"Collect SYMBOLS values.
If symbol is a variable, collect (symbol . value).
If symbol is a function name, collect (symbol . (funcall symbol)).
If symbol is a list, collect (symbol . (eval symbol)).
Otherwise, collect symbol."
(let (data)
(dolist (var symbols)
(pcase var
((pred keywordp) (push var data))
((and (pred symbolp) (pred boundp))
(unless (and emacs-gc-stats-inhibit-command-name-logging
(memq var '( this-command real-this-command
last-command real-last-command)))
(push (cons var (symbol-value var)) data)))
((pred functionp)
(push (cons var (funcall var)) data))
((pred listp)
(push (cons var (eval var)) data))
(_ (push var data))))
(nreverse data)))
(defvar emacs-gc-stats--data nil
"Collected statistics.")
(defun emacs-gc-stats--collect-init ()
"Collect initial stats."
(push
(apply #'emacs-gc-stats--collect
"Initial stats"
(current-time-string)
emacs-gc-stats-setting-vars)
emacs-gc-stats--data))
(defun emacs-gc-stats--collect-gc ()
"Collect single GC stats."
(push
(apply #'emacs-gc-stats--collect
(current-time-string)
emacs-gc-stats-command-vars)
emacs-gc-stats--data))
(defun emacs-gc-stats--collect-init-end ()
"Collect init.el stats."
(push
(apply #'emacs-gc-stats--collect
"Init.el stats"
(current-time-string)
emacs-gc-stats-summary-vars)
emacs-gc-stats--data))
(defun emacs-gc-stats--collect-end ()
"Collect initial stats."
(push
(apply #'emacs-gc-stats--collect
"Session end stats"
(current-time-string)
emacs-gc-stats-summary-vars)
emacs-gc-stats--data))
(defun emacs-gc-stats--get-repvious-session-data ()
"Return previously saved session data."
(and (file-readable-p emacs-gc-stats-file)
(with-temp-buffer
(insert-file-contents emacs-gc-stats-file)
(ignore-errors (read (current-buffer))))))
(defun emacs-gc-stats-save-session ()
"Save stats to disk."
(interactive)
(emacs-gc-stats--collect-end)
(let ((previous-sessions (emacs-gc-stats--get-repvious-session-data))
(session (reverse emacs-gc-stats--data))
(write-region-inhibit-fsync t)
;; We set UTF-8 here to avoid the overhead from
;; `find-auto-coding'.
(coding-system-for-write 'utf-8)
print-level
print-length
print-quoted
(print-escape-control-characters t)
(print-escape-nonascii t)
(print-continuous-numbering t)
print-number-table)
;; remove end data in case if we continue recording.
(pop emacs-gc-stats--data)
(with-temp-file emacs-gc-stats-file
;; Override partially saved session.
(let ((existing (assoc (car session) previous-sessions)))
(if existing
(setcdr (cdr existing) (cdr session))
(push session previous-sessions)))
(prin1 previous-sessions (current-buffer)))
(when
(and (called-interactively-p 'interactive)
(yes-or-no-p
(format "GC stats saved to \"%s\". Send email to emacs-gc-stats@gnu.org? " emacs-gc-stats-file)))
(browse-url "mailto:emacs-gc-stats@gnu.org"))
(message "GC stats saved to \"%s\". You can share the file by sending email to emacs-gc-stats@gnu.org" emacs-gc-stats-file)))
(defvar emacs-gc-stats-mode) ; defined later
(defun emacs-gc-stats-clear ()
"Clear GC stats collected so far."
(interactive)
(setq emacs-gc-stats--data nil)
;; Restart.
(when emacs-gc-stats-mode
(emacs-gc-stats-mode -1)
(emacs-gc-stats-mode +1)))
(defvar emacs-gc-stats-idle-delay 300
"Delay in seconds to count idle time.")
(defvar emacs-gc-stats--idle-tic 0
"Idle counter.
Idle time is counted with `emacs-gc-stats-idle-delay' granularity.")
(defvar emacs-gc-stats--idle-timer nil
"Time counting idle time.")
(defun emacs-gc-stats-idle-tic ()
"Increase idle counter."
(when (and (current-idle-time)
(> (time-to-seconds (current-idle-time)) emacs-gc-stats-idle-delay))
(cl-incf emacs-gc-stats--idle-tic)))
(defvar emacs-gc-stats--gc-old nil
"Alist of variable symbols and values storing original GC settings.")
(defun emacs-gc-stats--set-gc-defaults (&optional restore)
"Set GC settings according to `emacs-gc-stats-gc-defaults'.
Revert original settings when RESTORE is non-nil."
(if restore
(dolist (pair emacs-gc-stats--gc-old)
(set (car pair) (cdr pair)))
(dolist (var '(gc-cons-threshold gc-cons-percentage))
(push (cons (symbol-name var) (symbol-value var))
emacs-gc-stats--gc-old))
(pcase emacs-gc-stats-gc-defaults
(`nil nil)
(`emacs-defaults
(setq gc-cons-threshold 800000
gc-cons-percentage 0.1))
(other (error "Unknown value of `emacs-gc-stats-gc-defaults': %S" other)))))
(defun emacs-gc-stats--remind-maybe ()
"Show a reminder according to `emacs-gc-stats-remind'."
(require 'notifications)
(when emacs-gc-stats-remind
(when-let* ((days-threshold (if (numberp emacs-gc-stats-remind)
emacs-gc-stats-remind 21))
(first-session (or (car (last (emacs-gc-stats--get-repvious-session-data)))
(reverse emacs-gc-stats--data)))
(first-record (car first-session))
(first-date-string
(if (equal "Initial stats" (car first-record))
(cadr first-record) (car first-record)))
(first-time (parse-time-string first-date-string))
(days-passed (time-to-number-of-days
(time-subtract (current-time)
(encode-time first-time)))))
(when (> days-passed days-threshold)
(notifications-notify
:title "emacs-gc-stats reminder"
:body
(format
"%.1f days have passed since first record.
Consider M-x emacs-gc-stats-save-session or reporting back to emacs-gc-stats@gnu.org"
days-passed))
(warn
"emacs-gc-stats: %.1f days have passed since first record.
Consider M-x emacs-gc-stats-save-session or reporting back to emacs-gc-stats@gnu.org"
days-passed)))))
;;;###autoload
(define-minor-mode emacs-gc-stats-mode
"Toggle collecting Emacs GC statistics."
:global t
(if emacs-gc-stats-mode
(progn
(emacs-gc-stats--set-gc-defaults)
(add-hook 'after-init-hook #'emacs-gc-stats--set-gc-defaults)
(add-hook 'after-init-hook #'emacs-gc-stats--remind-maybe)
(add-hook 'kill-emacs-hook #'emacs-gc-stats--remind-maybe)
(unless emacs-gc-stats--data
(emacs-gc-stats--collect-init))
;; 5 minutes counter.
(setq emacs-gc-stats--idle-timer
(run-with-timer
emacs-gc-stats-idle-delay
emacs-gc-stats-idle-delay
#'emacs-gc-stats-idle-tic))
(add-hook 'post-gc-hook #'emacs-gc-stats--collect-gc)
(add-hook 'after-init-hook #'emacs-gc-stats--collect-init-end)
(add-hook 'kill-emacs-hook #'emacs-gc-stats-save-session))
(remove-hook 'after-init-hook #'emacs-gc-stats--set-gc-defaults)
(remove-hook 'after-init-hook #'emacs-gc-stats--remind-maybe)
(remove-hook 'kill-emacs-hook #'emacs-gc-stats--remind-maybe)
(emacs-gc-stats--set-gc-defaults 'restore)
(when (timerp emacs-gc-stats--idle-timer)
(cancel-timer emacs-gc-stats--idle-timer))
(remove-hook 'post-gc-hook #'emacs-gc-stats--collect-gc)
(remove-hook 'after-init-hook #'emacs-gc-stats--collect-init-end)
(remove-hook 'kill-emacs-hook #'emacs-gc-stats-save-session)))
(provide 'emacs-gc-stats)
;;; emacs-gc-stats.el ends here
;; Generated package description from emacs-gc-stats.el -*- no-byte-compile: t -*-
(define-package "emacs-gc-stats" "1.3" "Collect Emacs GC statistics" '((emacs "25.1")) :commit "f17fd30098c284a11feb06d505308d1f85ba2c55" :authors '(("Ihor Radchenko" . "yantar92@posteo.net")) :maintainer '("Ihor Radchenko" . "yantar92@posteo.net") :url "https://git.sr.ht/~yantar92/emacs-gc-stats")
;;; emacs-gc-stats-autoloads.el --- automatically extracted autoloads -*- lexical-binding: t -*-
;;
;;; Code:
(add-to-list 'load-path (directory-file-name
(or (file-name-directory #$) (car load-path))))
;;;### (autoloads nil "emacs-gc-stats" "emacs-gc-stats.el" (0 0 0
;;;;;; 0))
;;; Generated autoloads from emacs-gc-stats.el
(defvar emacs-gc-stats-mode nil "\
Non-nil if Emacs-Gc-Stats mode is enabled.
See the `emacs-gc-stats-mode' command
for a description of this minor mode.
Setting this variable directly does not take effect;
either customize it (see the info node `Easy Customization')
or call the function `emacs-gc-stats-mode'.")
(custom-autoload 'emacs-gc-stats-mode "emacs-gc-stats" nil)
(autoload 'emacs-gc-stats-mode "emacs-gc-stats" "\
Toggle collecting Emacs GC statistics.
This is a minor mode. If called interactively, toggle the
`Emacs-Gc-Stats mode' mode. If the prefix argument is positive,
enable the mode, and if it is zero or negative, disable the mode.
If called from Lisp, toggle the mode if ARG is `toggle'. Enable
the mode if ARG is nil, omitted, or is a positive number.
Disable the mode if ARG is a negative number.
To check whether the minor mode is enabled in the current buffer,
evaluate `(default-value \\='emacs-gc-stats-mode)'.
The mode's hook is called both when the mode is enabled and when
it is disabled.
\(fn &optional ARG)" t nil)
(register-definition-prefixes "emacs-gc-stats" '("emacs-gc-stats-"))
;;;***
;;;### (autoloads nil nil ("emacs-gc-stats-pkg.el") (0 0 0 0))
;;;***
;; Local Variables:
;; version-control: never
;; no-byte-compile: t
;; no-update-autoloads: t
;; coding: utf-8
;; End:
;;; emacs-gc-stats-autoloads.el ends here
# -*- after-save-hook: (org-md-export-to-markdown); -*-
#+options: toc:nil
This package collects Emacs garbage collection (GC) statistics over
time and saves it in the format that can be shared with Emacs
maintainers.
Context:
- https://yhetil.org/emacs-devel/20230310110747.4hytasakomvdyf7i@Ergus/
- https://yhetil.org/emacs-devel/87v8j6t3i9.fsf@localhost/
See the source code for information how to contact the author.
* Usage
Add
#+begin_src emacs-lisp
(require 'emacs-gc-stats)
;; Optionally reset Emacs GC settings to default values (recommended)
(setq emacs-gc-stats-gc-defaults 'emacs-defaults)
;; Optionally set reminder to upload the stats after 3 weeks.
(setq emacs-gc-stats-remind t) ; can also be a number of days
;; Optionally disable logging the command names
;; (setq emacs-gc-stats-inhibit-command-name-logging t)
(emacs-gc-stats-mode +1)
#+end_src
to your init file to enable the statistics acquiring.
When you are ready to share the results, run =M-x emacs-gc-stats-save-session=
and then share the saved ~emacs-gc-stats-file~ (defaults to
=~/.emacs.d/emacs-gc-stats.eld=) by sending an email attachment to
mailto:emacs-gc-stats@gnu.org. You can review the file before
sharing--it is a text file.
Configure ~emacs-gc-stats-remind~ to make Emacs display a reminder about
sharing the results.
* Security considerations
This package *does not* upload anything automatically. You will need to
upload the data manually, by sending email attachment. If necessary,
you can review ~emacs-gc-stats-file~ (defaults to
=~/.emacs.d/emacs-gc-stats.eld=) before uploading--it is just a text
file.
The following data is being collected after every command:
- GC settings ~gc-cons-threshold~ and ~gc-cons-percentage~
- Emacs version and whether Emacs framework (Doom, Prelude, etc) is used
- Whether ~gcmh-mode~ is used
- Idle time and Emacs uptime
- Available OS memory (see ~memory-info~)
- Emacs memory allocation/GC stats
- Current command name (potentially sensitive data, can be disabled)
- Timestamp when every GC is finished
Logging the command names can be disabled by setting
~emacs-gc-stats-inhibit-command-name-logging~ customization.
What exactly is being logger is controlled by
~emacs-gc-stats-setting-vars~, ~emacs-gc-stats-command-vars~, and
~emacs-gc-stats-summary-vars~.
You can use =M-x emacs-gc-stats-clear= to clear the currently collected
session data.
You can pause the logging any time by disabling ~emacs-gc-stats-mode~
(=M-x emacs-gc-stats-mode=).
* News
** Version 1.3
- New customization: ~emacs-gc-stats-inhibit-command-name-logging~ to
disable logging current command name. Logging is enabled by default.
- New customization: ~emacs-gc-stats-remind~ to set a reminder to share
the data. Reminder is disabled by default.
- The data being collected is can now be customized using
~emacs-gc-stats-setting-vars~, ~emacs-gc-stats-command-vars~, and
~emacs-gc-stats-summary-vars~.
This package collects Emacs garbage collection (GC) statistics over time
and saves it in the format that can be shared with Emacs maintainers.
Context:
• <https://yhetil.org/emacs-devel/20230310110747.4hytasakomvdyf7i@Ergus/>
• <https://yhetil.org/emacs-devel/87v8j6t3i9.fsf@localhost/>
See the source code for information how to contact the author.
1 Usage
═══════
Add
┌────
│ (require 'emacs-gc-stats)
│ ;; Optionally reset Emacs GC settings to default values (recommended)
│ (setq emacs-gc-stats-gc-defaults 'emacs-defaults)
│ ;; Optionally set reminder to upload the stats after 3 weeks.
│ (setq emacs-gc-stats-remind t) ; can also be a number of days
│ ;; Optionally disable logging the command names
│ ;; (setq emacs-gc-stats-inhibit-command-name-logging t)
│ (emacs-gc-stats-mode +1)
└────
to your init file to enable the statistics acquiring.
When you are ready to share the results, run `M-x
emacs-gc-stats-save-session' and then share the saved
`emacs-gc-stats-file' (defaults to `~/.emacs.d/emacs-gc-stats.eld') by
sending an email attachment to <mailto:emacs-gc-stats@gnu.org>. You
can review the file before sharing–it is a text file.
Configure `emacs-gc-stats-remind' to make Emacs display a reminder
about sharing the results.
2 Security considerations
═════════════════════════
This package *does not* upload anything automatically. You will need
to upload the data manually, by sending email attachment. If
necessary, you can review `emacs-gc-stats-file' (defaults to
`~/.emacs.d/emacs-gc-stats.eld') before uploading–it is just a text
file.
The following data is being collected after every command:
• GC settings `gc-cons-threshold' and `gc-cons-percentage'
• Emacs version and whether Emacs framework (Doom, Prelude, etc) is
used
• Whether `gcmh-mode' is used
• Idle time and Emacs uptime
• Available OS memory (see `memory-info')
• Emacs memory allocation/GC stats
• Current command name (potentially sensitive data, can be disabled)
• Timestamp when every GC is finished
Logging the command names can be disabled by setting
`emacs-gc-stats-inhibit-command-name-logging' customization.
What exactly is being logger is controlled by
`emacs-gc-stats-setting-vars', `emacs-gc-stats-command-vars', and
`emacs-gc-stats-summary-vars'.
You can use `M-x emacs-gc-stats-clear' to clear the currently
collected session data.
You can pause the logging any time by disabling `emacs-gc-stats-mode'
(`M-x emacs-gc-stats-mode').
3 News
══════
3.1 Version 1.3
───────────────
• New customization: `emacs-gc-stats-inhibit-command-name-logging' to
disable logging current command name. Logging is enabled by
default.
• New customization: `emacs-gc-stats-remind' to set a reminder to
share the data. Reminder is disabled by default.
• The data being collected is can now be customized using
`emacs-gc-stats-setting-vars', `emacs-gc-stats-command-vars', and
`emacs-gc-stats-summary-vars'.
README.md
;****************************************** -*- lexical-binding: t; -*- ***
;* *
;* OCaml *
;* *
;* Xavier Leroy and Jacques Garrigue *
;* *
;* Copyright 1997 Institut National de Recherche en Informatique et *
;* en Automatique. *
;* *
;* All rights reserved. This file is distributed under the terms of *
;* the GNU General Public License. *
;* *
;**************************************************************************
;;; inf-caml.el --- run the OCaml toplevel in an Emacs buffer
;; Xavier Leroy, july 1993.
;; modified by Jacques Garrigue, july 1997.
(require 'comint)
(require 'caml)
;; User modifiable variables
;; Whether you want the output buffer to be displayed when you send a phrase
(defvar caml-display-when-eval t
"*If true, display the inferior caml buffer when evaluating expressions.")
;; End of User modifiable variables
(defvar inferior-caml-mode-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map comint-mode-map)
map))
;; Augment Caml mode, so you can process OCaml code in the source files.
(defvar inferior-caml-program "ocaml"
"*Program name for invoking an inferior OCaml from Emacs.")
(define-derived-mode inferior-caml-mode comint-mode "Inferior-Caml"
"Major mode for interacting with an inferior OCaml process.
Runs an OCaml toplevel as a subprocess of Emacs, with I/O through an
Emacs buffer. A history of input phrases is maintained. Phrases can
be sent from another buffer in Caml mode."
(setq comint-prompt-regexp "^# ?")
(make-local-variable 'paragraph-start)
(setq paragraph-start (concat "^$\\|" page-delimiter))
(make-local-variable 'paragraph-separate)
(setq paragraph-separate paragraph-start)
(make-local-variable 'paragraph-ignore-fill-prefix)
(setq paragraph-ignore-fill-prefix t)
(make-local-variable 'require-final-newline)
(setq require-final-newline t)
(make-local-variable 'comment-start)
(setq comment-start "(*")
(make-local-variable 'comment-end)
(setq comment-end "*)")
(make-local-variable 'comment-column)
(setq comment-column 40)
(make-local-variable 'comment-start-skip)
(setq comment-start-skip "(\\*+ *")
(make-local-variable 'parse-sexp-ignore-comments)
(setq parse-sexp-ignore-comments nil)
;; Hook names should end in `-hook', not `-hooks'!
(run-hooks 'inferior-caml-mode-hooks))
(make-obsolete-variable 'inferior-caml-mode-hooks
'inferior-caml-mode-hook "Jan 2021")
(defconst inferior-caml-buffer-subname "inferior-caml")
(defconst inferior-caml-buffer-name
(concat "*" inferior-caml-buffer-subname "*"))
;; for compatibility with xemacs
(defun caml-sit-for (second &optional mili redisplay)
(sit-for (if mili (+ second (* mili 0.001)) second) redisplay))
;; To show result of evaluation at toplevel
(defvar inferior-caml-output nil)
(defun inferior-caml-signal-output (s)
(if (string-match "[^ ]" s) (setq inferior-caml-output t)))
(defun inferior-caml-mode-output-hook ()
(set-variable 'comint-output-filter-functions
(list (function inferior-caml-signal-output))
t))
;; FIXME: Why not put that directly in the major mode function?
(add-hook 'inferior-caml-mode-hook #'inferior-caml-mode-output-hook)
;; To launch ocaml whenever needed
(defun caml-run-process-if-needed (&optional cmd)
(if (comint-check-proc inferior-caml-buffer-name) nil
(if (not cmd)
(if (comint-check-proc inferior-caml-buffer-name)
(setq cmd inferior-caml-program)
(setq cmd (read-from-minibuffer "OCaml toplevel to run: "
inferior-caml-program))))
(setq inferior-caml-program cmd)
(let ((cmdlist (inferior-caml-args-to-list cmd))
(process-connection-type nil))
(set-buffer (apply (function make-comint)
inferior-caml-buffer-subname
(car cmdlist) nil (cdr cmdlist)))
(inferior-caml-mode)
(display-buffer inferior-caml-buffer-name)
t)
(setq caml-shell-active t)
))
;; patched to from original run-caml sharing code with
;; caml-run-process-when-needed
(defun run-caml (&optional cmd)
"Run an inferior OCaml process.
Input and output via buffer `*inferior-caml*'."
(interactive
(list (if (not (comint-check-proc inferior-caml-buffer-name))
(read-from-minibuffer "OCaml toplevel to run: "
inferior-caml-program))))
(caml-run-process-if-needed cmd)
(switch-to-buffer-other-window inferior-caml-buffer-name))
(defun inferior-caml-args-to-list (string)
(let ((where (string-match "[ \t]" string)))
(cond ((null where) (list string))
((not (= where 0))
(cons (substring string 0 where)
(inferior-caml-args-to-list (substring string (+ 1 where)
(length string)))))
(t (let ((pos (string-match "[^ \t]" string)))
(if (null pos)
nil
(inferior-caml-args-to-list (substring string pos
(length string)))))))))
(defun inferior-caml-show-subshell ()
(interactive)
(caml-run-process-if-needed)
(display-buffer inferior-caml-buffer-name)
; Added by Didier to move the point of inferior-caml to end of buffer
(let (;; (buf (current-buffer))
;; (caml-buf (get-buffer inferior-caml-buffer-name))
(count 0))
(while
(and (< count 10)
(not (equal (buffer-name (current-buffer))
inferior-caml-buffer-name)))
(next-multiframe-window)
(setq count (+ count 1)))
(if (equal (buffer-name (current-buffer))
inferior-caml-buffer-name)
(goto-char (point-max)))
(while
(> count 0)
(previous-multiframe-window)
(setq count (- count 1)))
)
)
;; patched by Didier to move cursor after evaluation
(defun inferior-caml-eval-region (start end)
"Send the current region to the inferior OCaml process."
(interactive "r")
(save-excursion (caml-run-process-if-needed))
(save-excursion
(goto-char end)
(caml-skip-comments-backward)
(comint-send-region inferior-caml-buffer-name start (point))
;; normally, ";;" are part of the region
(if (and (>= (point) 2)
(prog2 (backward-char 2) (looking-at ";;")))
(comint-send-string inferior-caml-buffer-name "\n")
(comint-send-string inferior-caml-buffer-name ";;\n"))
;; the user may not want to see the output buffer
(if caml-display-when-eval
(display-buffer inferior-caml-buffer-name t))))
;; jump to errors produced by ocaml compiler
(defun inferior-caml-goto-error (start _end)
"Jump to the location of the last error as indicated by inferior toplevel."
(interactive "r")
(let ((loc (+ start
(with-current-buffer (get-buffer inferior-caml-buffer-name)
(re-search-backward
(concat comint-prompt-regexp
"[ \t]*Characters[ \t]+\\([0-9]+\\)-[0-9]+:$"))
(string-to-number (match-string 1))))))
(goto-char loc)))
;;; original inf-caml.el ended here
;; as eval-phrase, but ignores errors.
(defun inferior-caml-just-eval-phrase (arg &optional min max)
"Send the phrase containing the point to the CAML process.
With prefix-arg send as many phrases as its numeric value,
ignoring possible errors during evaluation.
Optional arguments min max defines a region within which the phrase
should lies."
(interactive "p")
(let ((beg))
(while (> arg 0)
(setq arg (- arg 1))
(setq beg (caml-find-phrase min max))
(caml-eval-region beg (point)))
beg))
(defvar caml-previous-output nil
"Tells the beginning of output in the shell-output buffer, so that the
output can be retrieved later, asynchronously.")
;; enriched version of eval-phrase, to report errors.
(defun inferior-caml-eval-phrase (arg &optional min max)
"Send the phrase containing the point to the CAML process.
With prefix-arg send as many phrases as its numeric value,
If an error occurs during evaluation, stop at this phrase and
report the error.
Return nil if noerror and position of error if any.
If arg's numeric value is zero or negative, evaluate the current phrase
or as many as prefix arg, ignoring evaluation errors.
This allows to jump other erroneous phrases.
Optional arguments min max defines a region within which the phrase
should lies."
(interactive "p")
(if (save-excursion (caml-run-process-if-needed))
(progn
(setq inferior-caml-output nil)
(caml-wait-output 10 1)))
(if (< arg 1) (inferior-caml-just-eval-phrase (max 1 (- 0 arg)) min max)
(let ((proc (get-buffer-process inferior-caml-buffer-name))
(buf (current-buffer))
previous-output orig beg end err)
(save-window-excursion
(while (and (> arg 0) (not err))
(setq previous-output (marker-position (process-mark proc)))
(setq caml-previous-output previous-output)
(setq inferior-caml-output nil)
(setq orig (inferior-caml-just-eval-phrase 1 min max))
(caml-wait-output)
(switch-to-buffer inferior-caml-buffer-name nil)
(goto-char previous-output)
(cond ((re-search-forward
" *Characters \\([01-9][01-9]*\\)-\\([1-9][01-9]*\\):\n[^W]"
(point-max) t)
(setq beg (string-to-number (caml-match-string 1)))
(setq end (string-to-number (caml-match-string 2)))
(switch-to-buffer buf)
(goto-char orig)
(forward-char end)
(setq end (point))
(goto-char orig)
(forward-char beg)
(setq beg (point))
(setq err beg)
)
((looking-at
"Toplevel input:\n[>]\\([^\n]*\\)\n[>]\\(\\( *\\)^*\\)\n")
(let ((expr (caml-match-string 1))
(column (- (match-end 3) (match-beginning 3)))
(width (- (match-end 2) (match-end 3))))
(if (string-match "^\\(.*\\)[<]EOF[>]$" expr)
(setq expr (substring expr (match-beginning 1)
(match-end 1))))
(switch-to-buffer buf)
(re-search-backward
(concat "^" (regexp-quote expr) "$")
(- orig 10))
(goto-char (+ (match-beginning 0) column))
(setq end (+ (point) width)))
(setq err beg))
((looking-at
"Toplevel input:\n>[.]*\\([^.].*\n\\)\\([>].*\n\\)*[>]\\(.*[^.]\\)[.]*\n")
(let* ((e1 (caml-match-string 1))
(e2 (caml-match-string 3))
(expr
(concat
(regexp-quote e1) "\\(.*\n\\)*" (regexp-quote e2))))
(switch-to-buffer buf)
(re-search-backward expr orig 'move)
(setq end (match-end 0)))
(setq err beg))
(t
(switch-to-buffer buf)))
(setq arg (- arg 1))
)
(pop-to-buffer inferior-caml-buffer-name)
(if err
(goto-char (point-max))
(goto-char previous-output)
(goto-char (point-max)))
(pop-to-buffer buf))
(if err (progn (beep) (caml-overlay-region (point) end))
(if inferior-caml-output
(message "No error")
(message "No output yet...")
))
err)))
(defun caml-overlay-region (beg end &optional wait)
(interactive "%r")
(cond ((fboundp 'make-overlay)
(if caml-error-overlay ()
(setq caml-error-overlay (make-overlay 1 1))
(overlay-put caml-error-overlay 'face 'region))
(unwind-protect
(progn
(move-overlay caml-error-overlay beg end (current-buffer))
(beep) (if wait (read-event) (caml-sit-for 60)))
(delete-overlay caml-error-overlay)))))
;; wait some amount for output, that is, until inferior-caml-output is set
;; to true. Hence, interleaves sitting for shorts delays and checking the
;; flag. Give up after some time. Typing into the source buffer will cancel
;; waiting, i.e. may report 'No result yet'
(defun caml-wait-output (&optional before after)
(caml-sit-for 0 (or before 1))
(let ((c 1))
(while (and (not inferior-caml-output) (< c 99) (caml-sit-for 0 c t))
(setq c (+ c 1))))
(caml-sit-for (or after 0) 1))
;; To insert the last output from caml at point
(defun caml-insert-last-output ()
"Insert the result of the evaluation of previous phrase"
(interactive)
(let ((pos (process-mark (get-buffer-process inferior-caml-buffer-name))))
(insert-buffer-substring inferior-caml-buffer-name
caml-previous-output (- pos 2))))
;; additional bindings
;(let ((map (lookup-key caml-mode-map [menu-bar caml])))
; (define-key map [indent-buffer] '("Indent buffer" . caml-indent-buffer))
; (define-key map [eval-buffer] '("Eval buffer" . caml-eval-buffer))
;)
;(define-key caml-mode-map "\C-c\C-b" 'caml-eval-buffer)
(provide 'inf-caml)
;****************************************** -*- lexical-binding: t; -*- ***
;* *
;* OCaml *
;* *
;* Jacques Garrigue and Ian T Zimmerman *
;* *
;* Copyright 1997 Institut National de Recherche en Informatique et *
;* en Automatique. *
;* *
;* All rights reserved. This file is distributed under the terms of *
;* the GNU General Public License. *
;* *
;**************************************************************************
;;; Run camldebug under Emacs
;;; Derived from gdb.el.
;;; gdb.el is Copyright (C) 1988 Free Software Foundation, Inc, and is part
;;; of GNU Emacs
;;; Modified by Jerome Vouillon, 1994.
;;; Modified by Ian T. Zimmerman, 1996.
;;; Modified by Xavier Leroy, 1997.
;; This file is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation; either version 1, or (at your option)
;; any later version.
;; This file is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;;itz 04-06-96 I pondered basing this on gud. The potential advantages
;;were: automatic bugfix , keymaps and menus propagation.
;;Disadvantages: gud is not so clean itself, there is little common
;;functionality it abstracts (most of the stuff is done in the
;;debugger specific parts anyway), and, most seriously, gud sees it
;;fit to add C-x C-a bindings to the _global_ map, so there would be a
;;conflict between camldebug and gdb, for instance. While it's OK to
;;assume that a sane person doesn't use gdb and dbx at the same time,
;;it's not so OK (IMHO) for gdb and camldebug.
;; Xavier Leroy, 21/02/97: adaptation to ocamldebug.
(require 'comint)
(require 'shell)
(require 'caml)
(require 'derived)
(require 'thingatpt)
;;; Variables.
(defvar camldebug-last-frame)
(defvar camldebug-delete-prompt-marker)
(defvar camldebug-filter-accumulator nil)
(defvar camldebug-last-frame-displayed-p)
(defvar camldebug-filter-function)
(defvar camldebug-prompt-pattern "^(ocd) *"
"A regexp to recognize the prompt for ocamldebug.")
(defvar camldebug-overlay-event nil
"Overlay for displaying the current event.")
(defvar camldebug-overlay-under nil
"Overlay for displaying the current event.")
(defvar camldebug-event-marker nil
"Marker for displaying the current event.")
(defvar camldebug-track-frame t
"*If non-nil, always display current frame position in another window.")
(cond
(window-system
(make-face 'camldebug-event)
(make-face 'camldebug-underline)
(unless (face-differs-from-default-p 'camldebug-event)
(invert-face 'camldebug-event))
(unless (face-differs-from-default-p 'camldebug-underline)
(set-face-underline 'camldebug-underline t))
(setq camldebug-overlay-event (make-overlay 1 1))
(overlay-put camldebug-overlay-event 'face 'camldebug-event)
(setq camldebug-overlay-under (make-overlay 1 1))
(overlay-put camldebug-overlay-under 'face 'camldebug-underline))
(t
(setq camldebug-event-marker (make-marker))
(setq overlay-arrow-string "=>")))
;;; Camldebug mode.
(defvar comint-input-sentinel)
(define-derived-mode camldebug-mode comint-mode "Inferior CDB"
"Major mode for interacting with an inferior ocamldebug process.
The following commands are available:
\\{camldebug-mode-map}
\\[camldebug-display-frame] displays in the other window
the last line referred to in the camldebug buffer.
\\[camldebug-step], \\[camldebug-back] and \\[camldebug-next], in the camldebug
window, call camldebug to step, backstep or next and then update the other window
with the current file and position.
If you are in a source file, you may select a point to break
at, by doing \\[camldebug-break].
Commands:
Many commands are inherited from comint mode.
Additionally we have:
\\[camldebug-display-frame] display frames file in other window
\\[camldebug-step] advance one line in program
C-x SPACE sets break point at current line."
(mapc #'make-local-variable
'(camldebug-last-frame-displayed-p camldebug-last-frame
camldebug-delete-prompt-marker camldebug-filter-function
camldebug-filter-accumulator paragraph-start))
(setq
camldebug-last-frame nil
camldebug-delete-prompt-marker (make-marker)
camldebug-filter-accumulator ""
camldebug-filter-function #'camldebug-marker-filter
comint-prompt-regexp camldebug-prompt-pattern
paragraph-start comint-prompt-regexp
camldebug-last-frame-displayed-p t)
(add-hook 'comint-dynamic-complete-functions #'camldebug-complete nil t)
(make-local-variable 'shell-dirtrackp)
(setq shell-dirtrackp t)
(setq comint-input-sentinel #'shell-directory-tracker))
;;; Keymaps.
(defun camldebug-numeric-arg (arg)
(and arg (prefix-numeric-value arg)))
(defmacro def-camldebug (name key &optional doc args)
"Define camldebug-NAME to be a command sending NAME ARGS and bound
to KEY, with optional doc string DOC. Certain %-escapes in ARGS are
interpreted specially if present. These are:
%m module name of current module.
%d directory of current source file.
%c number of current character position
%e text of the caml variable surrounding point.
The `current' source file is the file of the current buffer (if
we're in a caml buffer) or the source file current at the last break
or step (if we're in the camldebug buffer), and the `current' module
name is the filename stripped of any *.ml* suffixes (this assumes the
usual correspondence between module and file naming is observed). The
`current' position is that of the current buffer (if we're in a source
file) or the position of the last break or step (if we're in the
camldebug buffer).
If a numeric is present, it overrides any ARGS flags and its string
representation is simply concatenated with the COMMAND."
(let* ((fun (intern (format "camldebug-%s" name))))
(list 'progn
(if doc
(list 'defun fun '(arg)
doc
'(interactive "P")
(list 'camldebug-call name args
'(camldebug-numeric-arg arg))))
(list 'define-key 'camldebug-mode-map
(concat "\C-c" key)
(list 'quote fun))
(list 'define-key 'caml-mode-map
(concat "\C-x\C-a" key)
(list 'quote fun)))))
(def-camldebug "step" "\C-s" "Step one event forward.")
(def-camldebug "backstep" "\C-k" "Step one event backward.")
(def-camldebug "run" "\C-r" "Run the program.")
(def-camldebug "reverse" "\C-v" "Run the program in reverse.")
(def-camldebug "last" "\C-l" "Go to latest time in execution history.")
(def-camldebug "backtrace" "\C-t" "Print the call stack.")
(def-camldebug "finish" "\C-f" "Finish executing current function.")
(def-camldebug "print" "\C-p" "Print value of symbol at point." "%e")
(def-camldebug "display" "\C-d" "Display value of symbol at point." "%e")
(def-camldebug "next" "\C-n" "Step one event forward (skip functions)")
(def-camldebug "up" "<" "Go up N stack frames (numeric arg) with display")
(def-camldebug "down" ">" "Go down N stack frames (numeric arg) with display")
(def-camldebug "break" "\C-b" "Set breakpoint at current line."
"@ \"%m\" # %c")
(defun camldebug-mouse-display (click)
"Display value of $NNN clicked on."
(interactive "e")
(let* ((start (event-start click))
(window (car start))
(pos (car (cdr start)))
symb)
(save-excursion
(select-window window)
(goto-char pos)
(setq symb (thing-at-point 'symbol))
(if (string-match "^\\$[0-9]+$" symb)
(camldebug-call "display" symb)))))
(define-key camldebug-mode-map [mouse-2] 'camldebug-mouse-display)
(defvar camldebug-kill-output)
(defun camldebug-kill-filter (string)
;gob up stupid questions :-)
(setq camldebug-filter-accumulator
(concat camldebug-filter-accumulator string))
(when (string-match "\\(.* \\)(y or n) " camldebug-filter-accumulator)
(setq camldebug-kill-output
(cons t (match-string 1 camldebug-filter-accumulator)))
(setq camldebug-filter-accumulator ""))
(if (string-match comint-prompt-regexp camldebug-filter-accumulator)
(let ((output (substring camldebug-filter-accumulator
(match-beginning 0))))
(setq camldebug-kill-output
(cons nil (substring camldebug-filter-accumulator 0
(1- (match-beginning 0)))))
(setq camldebug-filter-accumulator "")
output)
""))
(def-camldebug "kill" "\C-k")
(defvar current-camldebug-buffer nil)
(defvar camldebug-goto-output)
(defvar camldebug-goto-position)
(defun camldebug-kill ()
"Kill the program."
(interactive)
(let ((camldebug-kill-output))
(with-current-buffer current-camldebug-buffer
(let ((proc (get-buffer-process (current-buffer)))
(camldebug-filter-function #'camldebug-kill-filter))
(camldebug-call "kill")
(while (not (and camldebug-kill-output
(zerop (length camldebug-filter-accumulator))))
(accept-process-output proc))))
(if (not (car camldebug-kill-output))
(error (cdr camldebug-kill-output))
(sit-for 0 300)
(camldebug-call-1 (if (y-or-n-p (cdr camldebug-kill-output)) "y" "n")))))
;;FIXME: camldebug doesn't output the Hide marker on kill
(defun camldebug-goto-filter (string)
;accumulate onto previous output
(setq camldebug-filter-accumulator
(concat camldebug-filter-accumulator string))
;; Address Characters Kind Repr.
;; 14452 64-82 before/fun
;; 14584 182-217 after/ret
;;0: 30248 -1--1 pseudo
;;0: 30076 64-82 before/fun
(when (or (string-match
(concat "\\(?:\n\\|\\`\\)[ \t]*"
"\\([0-9]+\\)\\(?::[ \t]*\\([0-9]+\\)\\)?[ \t]+"
camldebug-goto-position
"-[0-9]+[ \t]*before.*\n")
camldebug-filter-accumulator)
(string-match
(concat "\\(?:\n\\|\\`\\)[ \t]*"
"\\([0-9]+\\)\\(?::[ \t]*\\([0-9]+\\)\\)?[ \t]+[0-9]+-"
camldebug-goto-position
"[ \t]*after.*\n")
camldebug-filter-accumulator))
(let ((id (match-string 1 camldebug-filter-accumulator))
(pos (match-string 2 camldebug-filter-accumulator)))
(setq camldebug-goto-output (if pos (concat id ":" pos) id)))
(setq camldebug-filter-accumulator
(substring camldebug-filter-accumulator (1- (match-end 0)))))
(when (string-match comint-prompt-regexp camldebug-filter-accumulator)
(setq camldebug-goto-output (or camldebug-goto-output 'fail))
(setq camldebug-filter-accumulator ""))
(if (string-match "\n\\(.*\\)\\'" camldebug-filter-accumulator)
(setq camldebug-filter-accumulator
(match-string 1 camldebug-filter-accumulator)))
"")
(def-camldebug "goto" "\C-g")
(defun camldebug-goto (&optional time)
"Go to the execution time TIME.
Without TIME, the command behaves as follows: In the camldebug buffer,
if the point at buffer end, goto time 0\; otherwise, try to obtain the
time from context around point. In a caml mode buffer, try to find the
time associated in execution history with the current point location.
With a negative TIME, move that many lines backward in the camldebug
buffer, then try to obtain the time from context around point."
(interactive "P")
(cond
(time
(let ((ntime (camldebug-numeric-arg time)))
(if (>= ntime 0) (camldebug-call "goto" nil ntime)
(save-selected-window
(select-window (get-buffer-window current-camldebug-buffer))
(save-excursion
(if (re-search-backward
"^Time *: [0-9]+ - pc *: [0-9]+\\(?::[0-9]+\\)? "
nil t (- 1 ntime))
(camldebug-goto nil)
(error "I don't have %d times in my history"
(- 1 ntime))))))))
((eq (current-buffer) current-camldebug-buffer)
(let ((time (cond
((eobp) 0)
((save-excursion
(beginning-of-line 1)
(looking-at
"^Time *: \\([0-9]+\\) - pc *: [0-9]+\\(?::[0-9]+\\)? "))
(string-to-number (match-string 1)))
((string-to-number (camldebug-format-command "%e"))))))
(camldebug-call "goto" nil time)))
(t
(let ((module (camldebug-module-name (buffer-file-name)))
(camldebug-goto-position (int-to-string (1- (point))))
(camldebug-goto-output) (address))
;get a list of all events in the current module
(with-current-buffer current-camldebug-buffer
(let* ((proc (get-buffer-process (current-buffer)))
(camldebug-filter-function #'camldebug-goto-filter))
(camldebug-call-1 (concat "info events " module))
(while (not (and camldebug-goto-output
(zerop (length camldebug-filter-accumulator))))
(accept-process-output proc))
(setq address (if (eq camldebug-goto-output 'fail) nil
(re-search-backward
(concat "^Time *: \\([0-9]+\\) - pc *: "
camldebug-goto-output
" - module "
module "$")
nil t)
(match-string 1)))))
(if address (camldebug-call "goto" nil (string-to-number address))
(error "No time at %s at %s" module camldebug-goto-position))))))
(defvar camldebug-delete-output)
(defvar camldebug-delete-position)
(defvar camldebug-delete-file)
(defun camldebug-delete-filter (string)
(setq camldebug-filter-accumulator
(concat camldebug-filter-accumulator string))
(when (string-match
;; Num Address Where
;; 1 14552 file u.ml, line 5, characters 1-34
;; 1 0: 30176 file u.ml, line 5, characters 1-34
(concat "\\(?:\n\\|\\`\\)[ \t]*\\([0-9]+\\)[ \t]+"
"[0-9]+\\(?::[ \t]*[0-9]+\\)?[ \t]+file +"
(regexp-quote camldebug-delete-file)
", character "
camldebug-delete-position "\n")
camldebug-filter-accumulator)
(setq camldebug-delete-output
(match-string 1 camldebug-filter-accumulator))
(setq camldebug-filter-accumulator
(substring camldebug-filter-accumulator (1- (match-end 0)))))
(when (string-match comint-prompt-regexp camldebug-filter-accumulator)
(setq camldebug-delete-output (or camldebug-delete-output 'fail))
(setq camldebug-filter-accumulator ""))
(if (string-match "\n\\(.*\\)\\'" camldebug-filter-accumulator)
(setq camldebug-filter-accumulator
(match-string 1 camldebug-filter-accumulator)))
"")
(def-camldebug "delete" "\C-d")
(defun camldebug-delete (&optional arg)
"Delete the breakpoint numbered ARG.
Without ARG, the command behaves as follows: In the camldebug buffer,
try to obtain the time from context around point. In a caml mode
buffer, try to find the breakpoint associated with the current point
location.
With a negative ARG, look for the -ARGth breakpoint pattern in the
camldebug buffer, then try to obtain the breakpoint info from context
around point."
(interactive "P")
(cond
(arg
(let ((narg (camldebug-numeric-arg arg)))
(if (> narg 0) (camldebug-call "delete" nil narg)
(with-current-buffer current-camldebug-buffer
(if (re-search-backward
"^Breakpoint [0-9]+ at [0-9]+\\(?::[0-9]+\\)? *: file "
nil t (- 1 narg))
(camldebug-delete nil)
(error "I don't have %d breakpoints in my history"
(- 1 narg)))))))
((eq (current-buffer) current-camldebug-buffer)
(let* ((bpline
"^Breakpoint \\([0-9]+\\) at [0-9]+\\(?::[0-9]+\\)? *: file ")
(arg (cond
((eobp)
(save-excursion (re-search-backward bpline nil t))
(string-to-number (match-string 1)))
((save-excursion
(beginning-of-line 1)
(looking-at bpline))
(string-to-number (match-string 1)))
((string-to-number (camldebug-format-command "%e"))))))
(camldebug-call "delete" nil arg)))
(t
(let ((camldebug-delete-file
(concat (camldebug-format-command "%m") ".ml"))
(camldebug-delete-position (camldebug-format-command "%c")))
(with-current-buffer current-camldebug-buffer
(let ((proc (get-buffer-process (current-buffer)))
(camldebug-filter-function #'camldebug-delete-filter)
(camldebug-delete-output))
(camldebug-call-1 "info break")
(while (not (and camldebug-delete-output
(zerop (length
camldebug-filter-accumulator))))
(accept-process-output proc))
(if (eq camldebug-delete-output 'fail)
(error "No breakpoint in %s at %s"
camldebug-delete-file
camldebug-delete-position)
(camldebug-call "delete" nil
(string-to-number camldebug-delete-output)))))))))
(defvar camldebug-complete-list)
(defun camldebug-complete-filter (string)
(setq camldebug-filter-accumulator
(concat camldebug-filter-accumulator string))
(while (string-match "\\(\n\\|\\`\\)\\(.+\\)\n"
camldebug-filter-accumulator)
(push (match-string 2 camldebug-filter-accumulator)
camldebug-complete-list)
(setq camldebug-filter-accumulator
(substring camldebug-filter-accumulator
(1- (match-end 0)))))
(when (string-match comint-prompt-regexp camldebug-filter-accumulator)
(setq camldebug-complete-list
(or camldebug-complete-list 'fail))
(setq camldebug-filter-accumulator ""))
(if (string-match "\n\\(.*\\)\\'" camldebug-filter-accumulator)
(setq camldebug-filter-accumulator
(match-string 1 camldebug-filter-accumulator)))
"")
(defun camldebug-complete ()
"Perform completion on the camldebug command preceding point."
(interactive)
(let* ((end (point))
(command (save-excursion
(beginning-of-line)
(and (looking-at comint-prompt-regexp)
(goto-char (match-end 0)))
(buffer-substring (point) end)))
(camldebug-complete-list nil) (command-word))
;; Find the word break. This match will always succeed.
(string-match "\\(\\`\\| \\)\\([^ ]*\\)\\'" command)
(setq command-word (match-string 2 command))
;itz 04-21-96 if we are trying to complete a word of nonzero
;length, chop off the last character. This is a nasty hack, but it
;works - in general, not just for this set of words: the comint
;call below will weed out false matches - and it avoids further
;mucking with camldebug's lexer.
(if (> (length command-word) 0)
(setq command (substring command 0 (1- (length command)))))
(let ((camldebug-filter-function #'camldebug-complete-filter))
(camldebug-call-1 (concat "complete " command))
(set-marker camldebug-delete-prompt-marker nil)
(while (not (and camldebug-complete-list
(zerop (length camldebug-filter-accumulator))))
(accept-process-output (get-buffer-process
(current-buffer)))))
(if (eq camldebug-complete-list 'fail)
(setq camldebug-complete-list nil))
(setq camldebug-complete-list
(sort camldebug-complete-list 'string-lessp))
(comint-dynamic-simple-complete command-word camldebug-complete-list)))
(define-key camldebug-mode-map "\C-l" 'camldebug-refresh)
(define-key camldebug-mode-map "\t" 'comint-dynamic-complete)
(define-key camldebug-mode-map "\M-?" 'comint-dynamic-list-completions)
(define-key caml-mode-map "\C-x " 'camldebug-break)
;;;###autoload
(defvar camldebug-command-name "ocamldebug"
"*Pathname for executing camldebug.")
;;;###autoload
(defun camldebug (path)
"Run camldebug on program FILE in buffer *camldebug-FILE*.
The directory containing FILE becomes the initial working directory
and source-file directory for camldebug. If you wish to change this, use
the camldebug commands `cd DIR' and `directory'."
(interactive "fRun ocamldebug on file: ")
(setq path (expand-file-name path))
(let ((file (file-name-nondirectory path)))
(pop-to-buffer (concat "*camldebug-" file "*"))
(setq default-directory (file-name-directory path))
(message "Current directory is %s" default-directory)
(make-comint (concat "camldebug-" file)
(substitute-in-file-name camldebug-command-name)
nil
"-emacs" "-cd" default-directory file)
(set-process-filter (get-buffer-process (current-buffer))
'camldebug-filter)
(set-process-sentinel (get-buffer-process (current-buffer))
'camldebug-sentinel)
(camldebug-mode)
(camldebug-set-buffer)))
(defun camldebug-set-buffer ()
(if (eq major-mode 'camldebug-mode)
(setq current-camldebug-buffer (current-buffer))
(save-selected-window (pop-to-buffer current-camldebug-buffer))))
;;; Filter and sentinel.
(defun camldebug-marker-filter (string)
(setq camldebug-filter-accumulator
(concat camldebug-filter-accumulator string))
(let ((output "") (begin))
;; Process all the complete markers in this chunk.
(while (setq begin
(string-match
"\032\032\\(H\\|M\\(.+\\):\\(.+\\):\\(.+\\):\\(before\\|after\\)\\)\n"
camldebug-filter-accumulator))
(setq camldebug-last-frame
(if (char-equal ?H (aref camldebug-filter-accumulator
(1+ (1+ begin))))
nil
(let ((isbefore
(string= "before"
(match-string 5 camldebug-filter-accumulator)))
(startpos (string-to-number
(match-string 3 camldebug-filter-accumulator)))
(endpos (string-to-number
(match-string 4 camldebug-filter-accumulator))))
(list (match-string 2 camldebug-filter-accumulator)
(if isbefore startpos endpos)
isbefore
startpos
endpos
)))
output (concat output
(substring camldebug-filter-accumulator
0 begin))
;; Set the accumulator to the remaining text.
camldebug-filter-accumulator (substring
camldebug-filter-accumulator
(match-end 0))
camldebug-last-frame-displayed-p nil))
;; Does the remaining text look like it might end with the
;; beginning of another marker? If it does, then keep it in
;; camldebug-filter-accumulator until we receive the rest of it. Since we
;; know the full marker regexp above failed, it's pretty simple to
;; test for marker starts.
(if (string-match "\032.*\\'" camldebug-filter-accumulator)
(progn
;; Everything before the potential marker start can be output.
(setq output (concat output (substring camldebug-filter-accumulator
0 (match-beginning 0))))
;; Everything after, we save, to combine with later input.
(setq camldebug-filter-accumulator
(substring camldebug-filter-accumulator (match-beginning 0))))
(setq output (concat output camldebug-filter-accumulator)
camldebug-filter-accumulator ""))
output))
(defun camldebug-filter (proc string)
(let ((output))
(if (buffer-name (process-buffer proc))
(let ((process-window))
;; it does not seem necessary to save excursion here,
;; since set-buffer as a temporary effect.
;; comint-output-filter explicitly avoids it.
;; in version 23, it prevents the marker to stay at end of buffer
(with-current-buffer (process-buffer proc)
;; If we have been so requested, delete the debugger prompt.
(if (marker-buffer camldebug-delete-prompt-marker)
(progn
(delete-region (process-mark proc)
camldebug-delete-prompt-marker)
(set-marker camldebug-delete-prompt-marker nil)))
(setq output (funcall camldebug-filter-function string))
;; Don't display the specified file unless
;; (1) point is at or after the position where output appears
;; and (2) this buffer is on the screen.
(setq process-window (and camldebug-track-frame
(not camldebug-last-frame-displayed-p)
(>= (point) (process-mark proc))
(get-buffer-window (current-buffer))))
;; Insert the text, moving the process-marker.
(comint-output-filter proc output))
;; if save-excursion is used (comint-next-prompt 1) would be needed
;; to move the mark past then next prompt, but this is not as good
;; as solution.
(if process-window
(save-selected-window
(select-window process-window)
(camldebug-display-frame)))))))
(defun camldebug-sentinel (proc msg)
(cond ((null (buffer-name (process-buffer proc)))
;; buffer killed
;; Stop displaying an arrow in a source file.
(camldebug-remove-current-event)
(set-process-buffer proc nil))
((memq (process-status proc) '(signal exit))
;; Stop displaying an arrow in a source file.
(camldebug-remove-current-event)
;; Fix the mode line.
(setq mode-line-process
(concat ": "
(symbol-name (process-status proc))))
(let* ((obuf (current-buffer)))
;; save-excursion isn't the right thing if
;; process-buffer is current-buffer
(unwind-protect
(progn
;; Write something in *compilation* and hack its mode line,
(set-buffer (process-buffer proc))
;; Force mode line redisplay soon
(set-buffer-modified-p (buffer-modified-p))
(if (eobp)
(insert ?\n mode-name " " msg)
(save-excursion
(goto-char (point-max))
(insert ?\n mode-name " " msg)))
;; If buffer and mode line will show that the process
;; is dead, we can delete it now. Otherwise it
;; will stay around until M-x list-processes.
(delete-process proc))
;; Restore old buffer, but don't restore old point
;; if obuf is the cdb buffer.
(set-buffer obuf))))))
(defun camldebug-refresh (&optional arg)
"Fix up a possibly garbled display, and redraw the mark."
(interactive "P")
(camldebug-display-frame)
(recenter arg))
(defun camldebug-display-frame ()
"Find, obey and delete the last filename-and-line marker from CDB.
The marker looks like \\032\\032Mfilename:startchar:endchar:beforeflag\\n.
Obeying it means displaying in another window the specified file and line."
(interactive)
(camldebug-set-buffer)
(if (not camldebug-last-frame)
(camldebug-remove-current-event)
(camldebug-display-line (nth 0 camldebug-last-frame)
(nth 3 camldebug-last-frame)
(nth 4 camldebug-last-frame)
(nth 2 camldebug-last-frame)))
(setq camldebug-last-frame-displayed-p t))
;; Make sure the file named TRUE-FILE is in a buffer that appears on the screen
;; and that its character CHARACTER is visible.
;; Put the mark on this character in that buffer.
(defvar pre-display-buffer-function)
(defun camldebug-display-line (true-file schar echar kind)
(let* ((pre-display-buffer-function nil) ; screw it, put it all in one screen
(pop-up-windows t)
(buffer (find-file-noselect true-file))
(window (display-buffer buffer t))
(spos) (epos) (pos))
(with-current-buffer buffer
(save-restriction
(widen)
(setq spos (+ (point-min) schar))
(setq epos (+ (point-min) echar))
(setq pos (if kind spos epos))
(camldebug-set-current-event spos epos (current-buffer) kind))
(cond ((or (< pos (point-min)) (> pos (point-max)))
(widen)
(goto-char pos))))
(set-window-point window pos)))
;;; Events.
(defun camldebug-remove-current-event ()
(if window-system
(progn
(delete-overlay camldebug-overlay-event)
(delete-overlay camldebug-overlay-under))
(setq overlay-arrow-position nil)))
(defun camldebug-set-current-event (spos epos buffer before)
(if window-system
(if before
(progn
(move-overlay camldebug-overlay-event spos (1+ spos) buffer)
(move-overlay camldebug-overlay-under
(+ spos 1) epos buffer))
(move-overlay camldebug-overlay-event (1- epos) epos buffer)
(move-overlay camldebug-overlay-under spos (- epos 1) buffer))
(with-current-buffer buffer
(goto-char spos)
(beginning-of-line)
(move-marker camldebug-event-marker (point))
(setq overlay-arrow-position camldebug-event-marker))))
;;; Miscellaneous.
(defun camldebug-module-name (filename)
(substring filename (string-match "\\([^/]*\\)\\.ml$" filename)
(match-end 1)))
;; The camldebug-call function must do the right thing whether its
;; invoking keystroke is from the camldebug buffer itself (via
;; major-mode binding) or a caml buffer. In the former case, we want
;; to supply data from camldebug-last-frame. Here's how we do it:
(defun camldebug-format-command (str)
(let* ((insource (not (eq (current-buffer) current-camldebug-buffer)))
(frame (if insource nil camldebug-last-frame)) (result))
(while (and str (string-match "\\([^%]*\\)%\\([mdcep]\\)" str))
(let ((key (string-to-char (substring str (match-beginning 2))))
(cmd (substring str (match-beginning 1) (match-end 1)))
(subst))
(setq str (substring str (match-end 2)))
(cond
((eq key ?m)
(setq subst (camldebug-module-name
(if insource (buffer-file-name) (nth 0 frame)))))
((eq key ?d)
(setq subst (file-name-directory
(if insource (buffer-file-name) (nth 0 frame)))))
((eq key ?c)
(setq subst (int-to-string
(if insource (1- (point)) (nth 1 frame)))))
((eq key ?e)
(setq subst (thing-at-point 'symbol))))
(setq result (concat result cmd subst))))
;; There might be text left in STR when the loop ends.
(concat result str)))
(defun camldebug-call (command &optional fmt arg)
"Invoke camldebug COMMAND displaying source in other window.
Certain %-escapes in FMT are interpreted specially if present.
These are:
%m module name of current module.
%d directory of current source file.
%c number of current character position
%e text of the caml variable surrounding point.
The `current' source file is the file of the current buffer (if
we're in a caml buffer) or the source file current at the last break
or step (if we're in the camldebug buffer), and the `current' module
name is the filename stripped of any *.ml* suffixes (this assumes the
usual correspondence between module and file naming is observed). The
`current' position is that of the current buffer (if we're in a source
file) or the position of the last break or step (if we're in the
camldebug buffer).
If ARG is present, it overrides any FMT flags and its string
representation is simply concatenated with the COMMAND."
;; Make sure debugger buffer is displayed in a window.
(camldebug-set-buffer)
(message "Command: %s" (camldebug-call-1 command fmt arg)))
(defun camldebug-call-1 (command &optional fmt arg)
;; Record info on the last prompt in the buffer and its position.
(with-current-buffer current-camldebug-buffer
(goto-char (process-mark (get-buffer-process current-camldebug-buffer)))
(let () ;;(pt (point))
(beginning-of-line)
(if (looking-at comint-prompt-regexp)
(set-marker camldebug-delete-prompt-marker (point)))))
(let ((cmd (cond
(arg (concat command " " (int-to-string arg)))
(fmt (camldebug-format-command
(concat command " " fmt)))
(command))))
(process-send-string (get-buffer-process current-camldebug-buffer)
(concat cmd "\n"))
cmd))
(provide 'camldebug)
;;; caml.el --- Caml mode for GNU Emacs -*- lexical-binding: t; -*-
;; Copyright (C) 1997-2017 Institut National de Recherche en Informatique et en Automatique.
;; Author: Jacques Garrigue <garrigue@kurims.kyoto-u.ac.jp>
;; Ian T Zimmerman <itz@rahul.net>
;; Damien Doligez <damien.doligez@inria.fr>
;; Maintainer: Christophe Troestler <Christophe.Troestler@umons.ac.be>
;; Created: July 1993
;; Package-Requires: ((emacs "24.3"))
;; Version: 4.10-snapshot
;; Keywords: OCaml
;; Homepage: https://github.com/ocaml/caml-mode
;; This file is not part of GNU Emacs.
;; This file is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation; either version 2, or (at your option)
;; any later version.
;; This file is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; A major mode for editing OCaml code (see <http://ocaml.org/>) in Emacs.
;; Some of its major features include:
;; - syntax highlighting (font lock);
;; - automatic indentation;
;; - querying the type of expressions (using compiler generated annot files);
;; - running an OCaml REPL within Emacs;
;; - scans declarations and places them in a menu.
;; The original indentation code was the work of Ian T Zimmerman and
;; was adapted for OCaml by Jacques Garrigue in July 1997.
;;; Code:
;;user customizable variables
(defvar caml-quote-char "'"
"*Quote for character constants. \"'\" for OCaml, \"`\" for Caml-Light.")
(defvar caml-imenu-enable nil
"*Enable Imenu support.")
(defvar caml-mode-indentation 2
"*Used for \\[caml-unindent-command].")
(defvar caml-lookback-limit 5000
"*How far to look back for syntax things in caml mode.")
(defvar caml-max-indent-priority 8
"*Bounds priority of operators permitted to affect caml indentation.
Priorities are assigned to `interesting' caml operators as follows:
all keywords 0 to 7 8
type, val, ... + 0 7
:: ^ 6
@ 5
:= <- 4
if 3
fun, let, match ... 2
module 1
opening keywords 0.")
(defvar caml-apply-extra-indent 2
"*How many spaces to add to indentation for an application in caml mode.")
(make-variable-buffer-local 'caml-apply-extra-indent)
(defvar caml-begin-indent 2
"*How many spaces to indent from a \"begin\" keyword in caml mode.")
(make-variable-buffer-local 'caml-begin-indent)
(defvar caml-class-indent 2
"*How many spaces to indent from a \"class\" keyword in caml mode.")
(make-variable-buffer-local 'caml-class-indent)
(defvar caml-exception-indent 2
"*How many spaces to indent from an \"exception\" keyword in caml mode.")
(make-variable-buffer-local 'caml-exception-indent)
(defvar caml-for-indent 2
"*How many spaces to indent from a \"for\" keyword in caml mode.")
(make-variable-buffer-local 'caml-for-indent)
(defvar caml-fun-indent 2
"*How many spaces to indent from a \"fun\" keyword in caml mode.")
(make-variable-buffer-local 'caml-fun-indent)
(defvar caml-function-indent 4
"*How many spaces to indent from a \"function\" keyword in caml mode.")
(make-variable-buffer-local 'caml-function-indent)
(defvar caml-if-indent 2
"*How many spaces to indent from an \"if\" keyword in caml mode.")
(make-variable-buffer-local 'caml-if-indent)
(defvar caml-if-else-indent 0
"*How many spaces to indent from an \"if .. else\" line in caml mode.")
(make-variable-buffer-local 'caml-if-else-indent)
(defvar caml-inherit-indent 2
"*How many spaces to indent from an \"inherit\" keyword in caml mode.")
(make-variable-buffer-local 'caml-inherit-indent)
(defvar caml-initializer-indent 2
"*How many spaces to indent from an \"initializer\" keyword in caml mode.")
(make-variable-buffer-local 'caml-initializer-indent)
(defvar caml-include-indent 2
"*How many spaces to indent from an \"include\" keyword in caml mode.")
(make-variable-buffer-local 'caml-include-indent)
(defvar caml-let-indent 2
"*How many spaces to indent from a \"let\" keyword in caml mode.")
(make-variable-buffer-local 'caml-let-indent)
(defvar caml-let-in-indent 0
"*How many spaces to indent from a \"let .. in\" keyword in caml mode.")
(make-variable-buffer-local 'caml-let-in-indent)
(defvar caml-match-indent 2
"*How many spaces to indent from a \"match\" keyword in caml mode.")
(make-variable-buffer-local 'caml-match-indent)
(defvar caml-method-indent 2
"*How many spaces to indent from a \"method\" keyword in caml mode.")
(make-variable-buffer-local 'caml-method-indent)
(defvar caml-module-indent 2
"*How many spaces to indent from a \"module\" keyword in caml mode.")
(make-variable-buffer-local 'caml-module-indent)
(defvar caml-object-indent 2
"*How many spaces to indent from an \"object\" keyword in caml mode.")
(make-variable-buffer-local 'caml-object-indent)
(defvar caml-of-indent 2
"*How many spaces to indent from an \"of\" keyword in caml mode.")
(make-variable-buffer-local 'caml-of-indent)
(defvar caml-parser-indent 4
"*How many spaces to indent from a \"parser\" keyword in caml mode.")
(make-variable-buffer-local 'caml-parser-indent)
(defvar caml-sig-indent 2
"*How many spaces to indent from a \"sig\" keyword in caml mode.")
(make-variable-buffer-local 'caml-sig-indent)
(defvar caml-struct-indent 2
"*How many spaces to indent from a \"struct\" keyword in caml mode.")
(make-variable-buffer-local 'caml-struct-indent)
(defvar caml-try-indent 2
"*How many spaces to indent from a \"try\" keyword in caml mode.")
(make-variable-buffer-local 'caml-try-indent)
(defvar caml-type-indent 4
"*How many spaces to indent from a \"type\" keyword in caml mode.")
(make-variable-buffer-local 'caml-type-indent)
(defvar caml-val-indent 2
"*How many spaces to indent from a \"val\" keyword in caml mode.")
(make-variable-buffer-local 'caml-val-indent)
(defvar caml-while-indent 2
"*How many spaces to indent from a \"while\" keyword in caml mode.")
(make-variable-buffer-local 'caml-while-indent)
(defvar caml-::-indent 2
"*How many spaces to indent from a \"::\" operator in caml mode.")
(make-variable-buffer-local 'caml-::-indent)
(defvar caml-@-indent 2
"*How many spaces to indent from a \"@\" operator in caml mode.")
(make-variable-buffer-local 'caml-@-indent)
(defvar caml-:=-indent 2
"*How many spaces to indent from a \":=\" operator in caml mode.")
(make-variable-buffer-local 'caml-:=-indent)
(defvar caml-<--indent 2
"*How many spaces to indent from a \"<-\" operator in caml mode.")
(make-variable-buffer-local 'caml-<--indent)
(defvar caml-->-indent 2
"*How many spaces to indent from a \"->\" operator in caml mode.")
(make-variable-buffer-local 'caml-->-indent)
(defvar caml-lb-indent 2
"*How many spaces to indent from a \"\[\" operator in caml mode.")
(make-variable-buffer-local 'caml-lb-indent)
(defvar caml-lc-indent 2
"*How many spaces to indent from a \"\{\" operator in caml mode.")
(make-variable-buffer-local 'caml-lc-indent)
(defvar caml-lp-indent 1
"*How many spaces to indent from a \"\(\" operator in caml mode.")
(make-variable-buffer-local 'caml-lp-indent)
(defvar caml-and-extra-indent nil
"*Extra indent for caml lines starting with the \"and\" keyword.
Usually negative. nil is align on master.")
(make-variable-buffer-local 'caml-and-extra-indent)
(defvar caml-do-extra-indent nil
"*Extra indent for caml lines starting with the \"do\" keyword.
Usually negative. nil is align on master.")
(make-variable-buffer-local 'caml-do-extra-indent)
(defvar caml-done-extra-indent nil
"*Extra indent for caml lines starting with the \"done\" keyword.
Usually negative. nil is align on master.")
(make-variable-buffer-local 'caml-done-extra-indent)
(defvar caml-else-extra-indent nil
"*Extra indent for caml lines starting with the \"else\" keyword.
Usually negative. nil is align on master.")
(make-variable-buffer-local 'caml-else-extra-indent)
(defvar caml-end-extra-indent nil
"*Extra indent for caml lines starting with the \"end\" keyword.
Usually negative. nil is align on master.")
(make-variable-buffer-local 'caml-end-extra-indent)
(defvar caml-in-extra-indent nil
"*Extra indent for caml lines starting with the \"in\" keyword.
Usually negative. nil is align on master.")
(make-variable-buffer-local 'caml-in-extra-indent)
(defvar caml-then-extra-indent nil
"*Extra indent for caml lines starting with the \"then\" keyword.
Usually negative. nil is align on master.")
(make-variable-buffer-local 'caml-then-extra-indent)
(defvar caml-to-extra-indent -1
"*Extra indent for caml lines starting with the \"to\" keyword.
Usually negative. nil is align on master.")
(make-variable-buffer-local 'caml-to-extra-indent)
(defvar caml-with-extra-indent nil
"*Extra indent for caml lines starting with the \"with\" keyword.
Usually negative. nil is align on master.")
(make-variable-buffer-local 'caml-with-extra-indent)
(defvar caml-comment-indent 3
"*Indent inside comments.")
(make-variable-buffer-local 'caml-comment-indent)
(defvar caml-|-extra-indent -2
"*Extra indent for caml lines starting with the | operator.
Usually negative. nil is align on master.")
(make-variable-buffer-local 'caml-|-extra-indent)
(defvar caml-rb-extra-indent -2
"*Extra indent for caml lines starting with ].
Usually negative. nil is align on master.")
(defvar caml-rc-extra-indent -2
"*Extra indent for caml lines starting with }.
Usually negative. nil is align on master.")
(defvar caml-rp-extra-indent -1
"*Extra indent for caml lines starting with ).
Usually negative. nil is align on master.")
(defvar caml-electric-indent t
"*Non-nil means electrically indent lines starting with |, ] or }.
Many people find electric keys irritating, so you can disable them if
you are one.")
(defvar caml-electric-close-vector t
"*Non-nil means electrically insert a | before a vector-closing ].
Many people find electric keys irritating, so you can disable them if
you are one. You should probably have this on, though, if you also
have `caml-electric-indent' on, which see.")
;;code
(defvar caml-shell-active nil
"Non nil when a subshell is running.")
(defvar caml-mode-map
(let ((map (make-sparse-keymap)))
(define-key map "|" 'caml-electric-pipe)
(define-key map "}" 'caml-electric-pipe)
(define-key map "]" 'caml-electric-rb)
(define-key map "\t" 'caml-indent-command)
(define-key map [backtab] 'caml-unindent-command)
;;itz 04-21-96 instead of defining a new function, use defadvice
;;that way we get out effect even when we do \C-x` in compilation buffer
;; (define-key map "\C-x`" 'caml-next-error)
;; caml-types
(define-key map [?\C-c?\C-t] 'caml-types-show-type) ; "type"
(define-key map [?\C-c?\C-f] 'caml-types-show-call) ; "function"
(define-key map [?\C-c?\C-l] 'caml-types-show-ident) ; "let"
;; must be a mouse-down event. Can be any button and any prefix
(define-key map [?\C-c down-mouse-1] 'caml-types-explore)
;; caml-help
(define-key map [?\C-c?i] 'ocaml-add-path)
(define-key map [?\C-c?\]] 'ocaml-close-module)
(define-key map [?\C-c?\[] 'ocaml-open-module)
(define-key map [?\C-c?\C-h] 'caml-help)
(define-key map [?\C-c?\t] 'caml-complete)
;; others
(define-key map "\C-cb" 'caml-insert-begin-form)
(define-key map "\C-cf" 'caml-insert-for-form)
(define-key map "\C-ci" 'caml-insert-if-form)
(define-key map "\C-cl" 'caml-insert-let-form)
(define-key map "\C-cm" 'caml-insert-match-form)
(define-key map "\C-ct" 'caml-insert-try-form)
(define-key map "\C-cw" 'caml-insert-while-form)
(define-key map "\C-c`" 'caml-goto-phrase-error)
(define-key map "\C-c\C-a" 'caml-find-alternate-file)
(define-key map "\C-c\C-c" 'compile)
(define-key map "\C-c\C-e" 'caml-eval-phrase)
(define-key map "\C-c\C-[" 'caml-backward-to-less-indent)
(define-key map "\C-c\C-]" 'caml-forward-to-less-indent)
(define-key map "\C-c\C-q" 'caml-indent-phrase)
(define-key map "\C-c\C-r" 'caml-eval-region)
(define-key map "\C-c\C-s" 'caml-show-subshell)
(define-key map "\M-\C-h" 'caml-mark-phrase)
(define-key map "\M-\C-q" 'caml-indent-phrase)
(define-key map "\M-\C-x" 'caml-eval-phrase)
(let ((menu (make-sparse-keymap "Caml"))
(forms (make-sparse-keymap "Forms")))
(define-key map "\C-c\C-d" 'caml-show-imenu)
(define-key map [menu-bar] (make-sparse-keymap))
(define-key map [menu-bar caml] (cons "Caml" menu))
;; caml-help
(define-key menu [open] '("Open add path" . ocaml-add-path ))
(define-key menu [close]
'("Close module for help" . ocaml-close-module))
(define-key menu [open] '("Open module for help" . ocaml-open-module))
(define-key menu [help] '("Help for identifier" . caml-help))
(define-key menu [complete] '("Complete identifier" . caml-complete))
(define-key menu [separator-help] '("---"))
;; caml-types
(define-key menu [show-type]
'("Show type at point" . caml-types-show-type ))
(define-key menu [separator-types] '("---"))
;; others
(define-key menu [camldebug] '("Call debugger..." . camldebug))
(define-key menu [run-caml] '("Start subshell..." . run-caml))
(define-key menu [compile] '("Compile..." . compile))
(define-key menu [switch-view]
'("Switch view" . caml-find-alternate-file))
(define-key menu [separator-format] '("--"))
(define-key menu [forms] (cons "Forms" forms))
(define-key menu [show-imenu] '("Show index" . caml-show-imenu))
(put 'caml-show-imenu 'menu-enable '(not caml-imenu-shown))
(define-key menu [show-subshell] '("Show subshell" . caml-show-subshell))
(put 'caml-show-subshell 'menu-enable 'caml-shell-active)
(define-key menu [eval-phrase] '("Eval phrase" . caml-eval-phrase))
(put 'caml-eval-phrase 'menu-enable 'caml-shell-active)
(define-key menu [indent-phrase] '("Indent phrase" . caml-indent-phrase))
(define-key forms [while]
'("while .. do .. done" . caml-insert-while-form))
(define-key forms [try] '("try .. with .." . caml-insert-try-form))
(define-key forms [match] '("match .. with .." . caml-insert-match-form))
(define-key forms [let] '("let .. in .." . caml-insert-let-form))
(define-key forms [if] '("if .. then .. else .." . caml-insert-if-form))
(define-key forms [begin] '("for .. do .. done" . caml-insert-for-form))
(define-key forms [begin] '("begin .. end" . caml-insert-begin-form)))
map)
"Keymap used in Caml mode.")
(defvar caml-mode-syntax-table
(let ((st (make-syntax-table)))
;; backslash is an escape sequence
(modify-syntax-entry ?\\ "\\" st)
;; ( is first character of comment start
(modify-syntax-entry ?\( "()1n" st)
;; * is second character of comment start,
;; and first character of comment end
(modify-syntax-entry ?* ". 23n" st)
;; ) is last character of comment end
(modify-syntax-entry ?\) ")(4" st)
;; backquote was a string-like delimiter (for character literals)
;; (modify-syntax-entry ?` "\"" st)
;; quote and underscore are part of words
(modify-syntax-entry ?' "w" st)
(modify-syntax-entry ?_ "w" st)
;; ISO-latin accented letters and EUC kanjis are part of words
(let ((i 160))
(while (< i 256)
(modify-syntax-entry i "w" st)
(setq i (1+ i))))
st)
"Syntax table in use in Caml mode buffers.")
(defvar caml-mode-abbrev-table nil
"Abbrev table used for Caml mode buffers.")
(if caml-mode-abbrev-table nil
(define-abbrev-table 'caml-mode-abbrev-table
(mapcar (lambda (keyword)
`(,keyword ,keyword caml-abbrev-hook nil t))
'("and" "do" "done" "else" "end" "in" "then" "with"))))
;; Other internal variables
(defvar caml-imenu-shown nil
"Non-nil if we have computed definition list.")
(make-variable-buffer-local 'caml-imenu-shown)
(defconst caml-imenu-search-regexp
(concat "\\_<in\\_>\\|"
"^[ \t]*\\(let\\|class\\|type\\|m\\(odule\\|ethod\\)"
"\\|functor\\|and\\|val\\)[ \t]+"
"\\(\\('[a-zA-Z0-9]+\\|([^)]+)"
"\\|mutable\\|private\\|rec\\|type\\)[ \t]+\\)?"
"\\([a-zA-Z][a-zA-Z0-9_']*\\)"))
;;; The major mode
(eval-when-compile
(require 'imenu))
;;
(defvar caml-mode-hook nil
"Hook for `caml-mode'.")
(define-derived-mode caml-mode prog-mode "caml"
"Major mode for editing OCaml code."
(setq local-abbrev-table caml-mode-abbrev-table)
(make-local-variable 'paragraph-start)
(setq paragraph-start (concat "^$\\|" page-delimiter))
(make-local-variable 'paragraph-separate)
(setq paragraph-separate paragraph-start)
(make-local-variable 'paragraph-ignore-fill-prefix)
(setq paragraph-ignore-fill-prefix t)
(make-local-variable 'require-final-newline)
(setq require-final-newline t)
(make-local-variable 'comment-start)
(setq comment-start "(*")
(make-local-variable 'comment-end)
(setq comment-end "*)")
(make-local-variable 'comment-column)
(setq comment-column 40)
(make-local-variable 'comment-start-skip)
(setq comment-start-skip "(\\*+ *")
(make-local-variable 'parse-sexp-ignore-comments)
(setq parse-sexp-ignore-comments nil)
(make-local-variable 'indent-line-function)
(setq indent-line-function #'caml-indent-command)
;itz Fri Sep 25 13:23:49 PDT 1998
(make-local-variable 'add-log-current-defun-function)
(setq add-log-current-defun-function #'caml-current-defun)
;garrigue 27-11-96
(setq case-fold-search nil)
;garrigue july 97
;; imenu support
(make-local-variable 'imenu-create-index-function)
(setq imenu-create-index-function #'caml-create-index-function)
(make-local-variable 'imenu-generic-expression)
(setq imenu-generic-expression caml-imenu-search-regexp)
(if (and caml-imenu-enable (< (buffer-size) 10000))
(caml-show-imenu)))
;; Disabled because it assumes make and does not play well with ocamlbuild.
;; See PR#4469 for details.
;; (defun caml-set-compile-command ()
;; "Hook to set compile-command locally, unless there is a Makefile or
;; a _build directory or a _tags file in the current directory."
;; (interactive)
;; (unless (or (null buffer-file-name)
;; (file-exists-p "makefile")
;; (file-exists-p "Makefile")
;; (file-exists-p "_build")
;; (file-exists-p "_tags"))
;; (let* ((filename (file-name-nondirectory buffer-file-name))
;; (basename (file-name-sans-extension filename))
;; (command nil))
;; (cond
;; ((string-match ".*\\.mli\$" filename)
;; (setq command "ocamlc -c"))
;; ((string-match ".*\\.ml\$" filename)
;; (setq command "ocamlc -c") ; (concat "ocamlc -o " basename)
;; )
;; ((string-match ".*\\.mll\$" filename)
;; (setq command "ocamllex"))
;; ((string-match ".*\\.mll\$" filename)
;; (setq command "ocamlyacc"))
;; )
;; (if command
;; (progn
;; (make-local-variable 'compile-command)
;; (setq compile-command (concat command " " filename))))
;; )))
;; (add-hook 'caml-mode-hook #'caml-set-compile-command)
;;; Auxiliary function. Garrigue 96-11-01.
(defun caml-find-alternate-file ()
"Find the `.mli' file for the open `.ml' file, or vice versa."
(interactive)
(let ((name (buffer-file-name)))
(if (string-match "^\\(.*\\)\\.\\(ml\\|mli\\)$" name)
(find-file
(concat
(caml-match-string 1 name)
(if (string= "ml" (caml-match-string 2 name)) ".mli" ".ml"))))))
;;; subshell support
(defun caml-eval-region (start end)
"Evaluate the region.
Send the current region bounded by START and END to the inferior
OCaml process."
(interactive"r")
(require 'inf-caml)
(declare-function inferior-caml-eval-region "inf-caml")
(inferior-caml-eval-region start end))
;; old version ---to be deleted later
;
; (defun caml-eval-phrase ()
; "Send the current OCaml phrase to the inferior Caml process."
; (interactive)
; (save-excursion
; (let ((bounds (caml-mark-phrase)))
; (inferior-caml-eval-region (car bounds) (cdr bounds)))))
(defun caml-eval-phrase (arg &optional min max)
"Send the phrase containing the point to the CAML process.
With a prefix argument send as many phrases as its numeric value,
If an error occurs during evaluation, stop at this phrase and
report the error.
Return nil if noerror and position of error if any.
If ARG's numeric value is zero or negative, evaluate the current phrase
or as many as prefix arg, ignoring evaluation errors.
This allows to jump other erroneous phrases.
Optional arguments MIN MAX defines a region within which the phrase
should lies."
(interactive "p")
(require 'inf-caml)
(declare-function inferior-caml-eval-phrase "inf-caml")
(inferior-caml-eval-phrase arg min max))
(defun caml-eval-buffer (arg)
"Evaluate the buffer from the beginning to the phrase under the point.
With a prefix ARG, evaluate past the whole buffer, no
stopping at the current point."
(interactive "p")
(let ((here (point)) err)
(goto-char (point-min))
(setq err
(caml-eval-phrase 500 (point-min) (if arg (point-max) here)))
(if err (set-mark err))
(goto-char here)))
(defun caml-show-subshell ()
"Start an inferior subshell."
(interactive)
(require 'inf-caml)
(declare-function inferior-caml-show-subshell "inf-caml")
(inferior-caml-show-subshell))
;;; Imenu support
(defun caml-show-imenu ()
"Open `imenu'."
(interactive)
(require 'imenu)
(switch-to-buffer (current-buffer))
(imenu-add-to-menubar "Defs")
(setq caml-imenu-shown t))
(defun caml-prev-index-position-function ()
"Locate the previous imenu entry."
(let (found data)
(while (and (setq found
(re-search-backward caml-imenu-search-regexp nil 'move))
(progn (setq data (match-data)) t)
(or (caml-in-literal-p)
(caml-in-comment-p)
(if (looking-at "in") (caml-find-in-match)))))
(set-match-data data)
found))
(defun caml-create-index-function ()
"Create an index alist for OCaml files.
See `imenu-create-index-function'."
(let (value-alist
type-alist
class-alist
method-alist
module-alist
and-alist
all-alist
menu-alist
(prev-pos (point-max))
index)
(goto-char prev-pos)
;; collect definitions
(while (caml-prev-index-position-function)
(setq index (cons (caml-match-string 5) (point)))
(setq all-alist (cons index all-alist))
(cond
((looking-at "[ \t]*and")
(setq and-alist (cons index and-alist)))
((looking-at "[ \t]*let")
(setq value-alist (cons index (append and-alist value-alist)))
(setq and-alist nil))
((looking-at "[ \t]*type")
(setq type-alist (cons index (append and-alist type-alist)))
(setq and-alist nil))
((looking-at "[ \t]*class")
(setq class-alist (cons index (append and-alist class-alist)))
(setq and-alist nil))
((looking-at "[ \t]*val")
(setq value-alist (cons index value-alist)))
((looking-at "[ \t]*\\(module\\|functor\\)")
(setq module-alist (cons index module-alist)))
((looking-at "[ \t]*method")
(setq method-alist (cons index method-alist)))))
;; build menu
(mapc
(lambda (pair)
(if (not (null (cdr pair)))
(setq menu-alist
(cons
(cons (car pair)
(sort (cdr pair) 'imenu--sort-by-name))
menu-alist))))
`(("Values" . ,value-alist)
("Types" . ,type-alist)
("Modules" . ,module-alist)
("Methods" . ,method-alist)
("Classes" . ,class-alist)))
(if all-alist (setq menu-alist (cons (cons "Index" all-alist) menu-alist)))
menu-alist))
;;; Indentation stuff
(defun caml-in-indentation ()
"Test if inside indentation.
This function tests whether all characters between beginning of
line and point are blanks."
(save-excursion
(skip-chars-backward " \t")
(bolp)))
;;; The command
;;; Sorry, I didn't like the previous behaviour... Garrigue 96/11/01
(defun caml-indent-command (&optional p)
"Indent the current line in Caml mode.
Compute new indentation based on Caml syntax. If prefixed P,
indent the line all the way to where point is."
(interactive "*p")
(cond
((and p (> p 1)) (indent-line-to (current-column)))
((caml-in-indentation) (indent-line-to (caml-compute-final-indent)))
(t (save-excursion
(indent-line-to
(caml-compute-final-indent))))))
(defun caml-unindent-command ()
"Decrease indentation by one level in Caml mode.
Works only if the point is at the beginning of an indented line
\(i.e., all characters between beginning of line and point are
blanks\). Does nothing otherwise. The unindent size is given by the
variable `caml-mode-indentation'."
(interactive "*")
(let* ((begline
(save-excursion
(beginning-of-line)
(point)))
(current-offset
(- (point) begline)))
(if (and (>= current-offset caml-mode-indentation)
(caml-in-indentation))
(backward-delete-char-untabify caml-mode-indentation))))
;;;
;;; Error processing
;;;
;; Error positions are given in bytes, not in characters
;; This function switches to monobyte mode
(require 'compile)
(defconst caml--error-regexp
(rx bol
(* " ")
(group ; 1: HIGHLIGHT
(or "File "
;; Exception backtrace.
(seq
(or "Raised at" "Re-raised at" "Raised by primitive operation at"
"Called from")
(* nonl) ; OCaml ≥4.11: " FUNCTION in"
" file "))
(group (? "\"")) ; 2
(group (+ (not (in "\t\n \",<>")))) ; 3: FILE
(backref 2)
(? " (inlined)")
", line" (? "s") " "
(group (+ (in "0-9"))) ; 4: LINE-START
(? "-" (group (+ (in "0-9")))) ; 5; LINE-END
(? ", character" (? "s") " "
(group (+ (in "0-9"))) ; 6: COL-START
(? "-" (group (+ (in "0-9"))))) ; 7: COL-END
;; Colon not present in backtraces.
(? ":"))
(? "\n"
(* (in "\t "))
(* (or (seq (+ (in "0-9"))
" | "
(* nonl))
(+ "^"))
"\n"
(* (in "\t ")))
(group "Warning" ; 8: WARNING
(? " " (+ (in "0-9")))
(? " [" (+ (in "a-z0-9-")) "]")
":")))
"Regular expression matching the error messages produced by ocamlc/ocamlopt.
Also matches source references in exception backtraces.")
(defun caml--end-column ()
"Return the end-column number in a parsed OCaml message.
OCaml uses exclusive end-columns but Emacs wants them to be inclusive."
(and (match-beginning 7)
(+ (string-to-number (match-string 7))
;; Prior to Emacs 28, the end-column function value was incorrectly
;; off by one.
(if (>= emacs-major-version 28) -1 0))))
(when (boundp 'compilation-error-regexp-alist-alist)
(push `(ocaml ,caml--error-regexp 3 (4 . 5) (6 . caml--end-column) (8) 1
(8 font-lock-function-name-face))
compilation-error-regexp-alist-alist))
(when (boundp 'compilation-error-regexp-alist)
(push 'ocaml compilation-error-regexp-alist))
;; A regexp to extract the range info
(defconst caml-error-chars-regexp
".*, .*, [A-\377]+ \\([0-9]+\\)-\\([0-9]+\\):?"
"Regular expression used by `next-error'.
This regular expression extracts the character numbers from an
error message produced by ocamlc.")
;; Wrapper around next-error.
(defvar caml-error-overlay nil)
(defvar caml-next-error-skip-warnings-flag nil)
;;itz 04-21-96 somebody didn't get the documentation for next-error
;;right. When the optional argument is a number n, it should move
;;forward n errors, not reparse.
;itz 04-21-96 instead of defining a new function, use defadvice
;that way we get our effect even when we do \C-x` in compilation buffer
(defadvice next-error (after caml-next-error activate)
"Read the extra positional information provided by the OCaml compiler.
Puts the point and the mark exactly around the erroneous program
fragment. The erroneous fragment is also temporarily highlighted if
possible."
(if (eq major-mode 'caml-mode)
(let (skip bol beg end)
(save-excursion
(with-current-buffer
(if (boundp 'compilation-last-buffer)
compilation-last-buffer ;Emacs 19
"*compilation*") ;Emacs 18
(save-excursion
(goto-char (window-point (get-buffer-window (current-buffer))))
(if (looking-at caml-error-chars-regexp)
(setq beg
(string-to-number
(buffer-substring (match-beginning 1) (match-end 1)))
end
(string-to-number
(buffer-substring (match-beginning 2) (match-end 2)))))
(forward-line 1)
(beginning-of-line)
(if (and (looking-at "Warning")
caml-next-error-skip-warnings-flag)
(setq skip 't)))))
(cond
(skip (next-error))
(beg
(setq end (- end beg))
(beginning-of-line)
(forward-char beg)
(setq beg (point))
(forward-char end)
(setq end (point))
(goto-char beg)
(push-mark end t)
(cond ((fboundp 'make-overlay)
(if caml-error-overlay ()
(setq caml-error-overlay (make-overlay 1 1))
(overlay-put caml-error-overlay 'face 'region))
(unwind-protect
(progn
(move-overlay caml-error-overlay
beg end (current-buffer))
(sit-for 60))
(delete-overlay caml-error-overlay)))))))))
(defun caml-next-error-skip-warnings (&rest args)
"Same as `next-error' but skip warnings.
For the arguments ARGS, see `next-error'."
(let ((old-flag caml-next-error-skip-warnings-flag))
(unwind-protect
(progn (setq caml-next-error-skip-warnings-flag 't)
(apply #'next-error args))
(setq caml-next-error-skip-warnings-flag old-flag))))
;; Usual match-string doesn't work properly with font-lock-mode
;; on some emacs.
(defun caml-match-string (num &optional string)
"Return string of text matched by last search, without properties.
NUM specifies which parenthesized expression in the last regexp.
Value is nil if NUMth pair didn't match, or there were less than
NUM pairs. Zero means the entire text matched by the whole regexp
or whole string. Uses STRING is given and otherwise extracts from
buffer."
(let* ((data (match-data))
(begin (nth (* 2 num) data))
(end (nth (1+ (* 2 num)) data)))
(if string (substring string begin end)
(buffer-substring-no-properties begin end))))
;; itz Thu Sep 24 19:02:42 PDT 1998 this is to have some level of
;; comfort when sending phrases to the toplevel and getting errors.
(defun caml-goto-phrase-error ()
"Find the error location in current OCaml phrase."
(interactive)
(require 'inf-caml)
(declare-function inferior-caml-goto-error "inf-caml")
(let ((bounds (save-excursion (caml-mark-phrase))))
(inferior-caml-goto-error (car bounds) (cdr bounds))))
;;; Phrases
;itz the heuristics used to see if we're `between two phrases'
;didn't seem right to me.
(defconst caml-phrase-start-keywords
(concat "\\_<\\(class\\|ex\\(ternal\\|ception\\)\\|functor"
"\\|let\\|module\\|open\\|type\\|val\\)\\_>")
"Keywords starting phrases in files.")
(defun caml-at-phrase-start-p ()
"Check if at the start of a phrase.
A phrase starts when a toplevel keyword is at the beginning of a
line."
(and (bolp)
(or (looking-at "#")
(looking-at caml-phrase-start-keywords))))
(defun caml-skip-comments-forward ()
"Skip forward past comments."
(skip-chars-forward " \n\t")
(while (or (looking-at comment-start-skip) (caml-in-comment-p))
(if (= (following-char) ?\)) (forward-char)
(search-forward comment-end))
(skip-chars-forward " \n\t")))
(defun caml-skip-comments-backward ()
"Skip backward past comments."
(skip-chars-backward " \n\t")
(while (and (eq (preceding-char) ?\)) (eq (char-after (- (point) 2)) ?*))
(backward-char)
(while (caml-in-comment-p) (search-backward comment-start))
(skip-chars-backward " \n\t")))
(defconst caml-phrase-sep-keywords (concat ";;\\|" caml-phrase-start-keywords))
(defun caml-find-phrase (&optional min-pos max-pos)
"Find the CAML phrase containing the point.
Return the position of the beginning of the phrase, and move
point to the end. Optionally operates between MIN-POS and
MAX-POS."
(interactive)
(if (not min-pos) (setq min-pos (point-min)))
(if (not max-pos) (setq max-pos (point-max)))
(let (beg end kwop)
;(caml-skip-comments-backward)
(cond
; shall we have special processing for semicolons?
;((and (eq (char-before (- (point) 1)) ?\;) (eq (char-before) ?\;))
; (forward-char)
; (caml-skip-comments-forward)
; (setq beg (point))
; (while (and (search-forward ";;" max-pos 'move)
; (or (caml-in-comment-p) (caml-in-literal-p)))))
(t
(caml-skip-comments-forward)
(if (caml-at-phrase-start-p) (forward-char))
(while (and (cond
((re-search-forward caml-phrase-sep-keywords max-pos 'move)
(goto-char (match-beginning 0)) t))
(or (not (or (bolp) (looking-at ";;")))
(caml-in-comment-p)
(caml-in-literal-p)))
(forward-char))
(setq end (+ (point) (if (looking-at ";;") 2 0)))
(while (and
(setq kwop (caml-find-kwop caml-phrase-sep-keywords min-pos))
(not (string= kwop ";;"))
(not (bolp))))
(if (string= kwop ";;") (forward-char 2))
(if (not kwop) (goto-char min-pos))
(caml-skip-comments-forward)
(setq beg (point))
(if (>= beg end) (error "No phrase before point"))
(goto-char end)))
(caml-skip-comments-forward)
beg))
(defun caml-mark-phrase (&optional min-pos max-pos)
"Put mark at end of this OCaml phrase, point at beginning.
Optionally operates between MIN-POS and MAX-POS."
(interactive)
(let* ((beg (caml-find-phrase min-pos max-pos)) (end (point)))
(push-mark)
(goto-char beg)
(cons beg end)))
;;itz Fri Sep 25 12:58:13 PDT 1998 support for adding change-log entries
(defun caml-current-defun ()
"Return the location of the definition around the point."
(save-excursion
(caml-mark-phrase)
(if (not (looking-at caml-phrase-start-keywords)) nil
(re-search-forward caml-phrase-start-keywords)
(let ((done nil))
(while (not done)
(cond
((looking-at "\\s ")
(skip-syntax-forward " "))
((char-equal (following-char) ?\( )
(forward-sexp 1))
((char-equal (following-char) ?')
(skip-syntax-forward "w_"))
(t (setq done t)))))
(re-search-forward "\\(\\sw\\|\\s_\\)+")
(match-string 0))))
(defun caml-overlap (b1 e1 b2 e2)
"Return non-nil if the closed ranges B1..E1 and B2..E2 overlap."
(<= (max b1 b2) (min e1 e2)))
(defun caml-in-literal-p ()
"Return non-nil if point is inside a caml literal."
(let* ((start-literal (concat "[\"" caml-quote-char "]"))
(char-literal
(concat "\\([^\\]\\|\\\\\\.\\|\\\\[0-9][0-9][0-9]\\)"
caml-quote-char))
(pos (point))
(eol (progn (end-of-line 1) (point)))
state in-str)
(beginning-of-line 1)
(while (and (not state)
(re-search-forward start-literal eol t)
(<= (point) pos))
(cond
((string= (caml-match-string 0) "\"")
(setq in-str t)
(while (and in-str (not state)
(re-search-forward "\"\\|\\\\\"" eol t))
(if (> (point) pos) (setq state t))
(if (string= (caml-match-string 0) "\"") (setq in-str nil)))
(if in-str (setq state t)))
((looking-at char-literal)
(if (and (>= pos (match-beginning 0)) (< pos (match-end 0)))
(setq state t)
(goto-char (match-end 0))))))
(goto-char pos)
state))
(defun caml-forward-comment ()
"Skip one (eventually nested) comment."
(let ((count 1) match)
(while (> count 0)
(if (not (re-search-forward "(\\*\\|\\*)" nil 'move))
(setq count -1)
(setq match (caml-match-string 0))
(cond
((caml-in-literal-p)
nil)
((string= match comment-start)
(setq count (1+ count)))
(t
(setq count (1- count))))))
(= count 0)))
(defun caml-backward-comment ()
"Skip one (eventually nested) comment."
(let ((count 1) match)
(while (> count 0)
(if (not (re-search-backward "(\\*\\|\\*)" nil 'move))
(setq count -1)
(setq match (caml-match-string 0))
(cond
((caml-in-literal-p)
nil)
((string= match comment-start)
(setq count (1- count)))
(t
(setq count (1+ count))))))
(= count 0)))
(defun caml-in-comment-p ()
"Return non-nil if point is inside a caml comment.
Returns nil for the parenthesis opening a comment."
(nth 4 (syntax-ppss)))
;; Various constants and regexps
(defconst caml-before-expr-prefix
(concat "\\_<\\(asr\\|begin\\|class\\|do\\(wnto\\)?\\|else"
"\\|i\\(f\\|n\\(herit\\|itializer\\)?\\)"
"\\|f\\(or\\|un\\(ct\\(ion\\|or\\)\\)?\\)"
"\\|l\\(and\\|or\\|s[lr]\\|xor\\)\\|m\\(atch\\|od\\)"
"\\|o[fr]\\|parser\\|s\\(ig\\|truct\\)\\|t\\(hen\\|o\\|ry\\)"
"\\|w\\(h\\(en\\|ile\\)\\|ith\\)\\)\\_>\\|:begin\\_>"
"\\|[=<>@^|&+-*/$%][!$%*+-./:<=>?@^|~]*\\|:[:=]\\|[[({,;]")
"Keywords that may appear immediately before an expression.
Used to distinguish it from toplevel let construct.")
(defconst caml-matching-kw-regexp
(concat
"\\_<\\(and\\|do\\(ne\\|wnto\\)?\\|e\\(lse\\|nd\\)\\|in\\|t\\(hen\\|o\\)"
"\\|with\\)\\_>\\|[^[|]|")
"Regexp used in caml mode for skipping back over nested blocks.")
(defconst caml-matching-kw-alist
'(("|" . caml-find-pipe-match)
(";" . caml-find-semi-match)
("," . caml-find-comma-match)
("end" . caml-find-end-match)
("done" . caml-find-done-match)
("in" . caml-find-in-match)
("with" . caml-find-with-match)
("else" . caml-find-else-match)
("then" . caml-find-then-match)
("to" . caml-find-done-match)
("downto" . caml-find-done-match)
("do" . caml-find-done-match)
("and" . caml-find-and-match))
"Association list used in caml mode for skipping back over nested blocks.")
(defconst caml-kwop-regexps (make-vector 9 nil)
"Array of regexps representing caml keywords of different priorities.")
(defun caml-in-shebang-line ()
"Check if in shebang."
(save-excursion
(beginning-of-line)
(and (= 1 (point)) (looking-at "#!"))))
(defun caml-in-expr-p ()
"Check if in expression."
(let ((pos (point)) (in-expr t))
(caml-find-kwop
(concat caml-before-expr-prefix "\\|"
caml-matching-kw-regexp "\\|"
(aref caml-kwop-regexps caml-max-indent-priority)))
(cond
; special case for #! at beginning of file
((caml-in-shebang-line) (setq in-expr nil))
; special case for ;;
((and (> (point) 1) (= (preceding-char) ?\;) (= (following-char) ?\;))
(setq in-expr nil))
((looking-at caml-before-expr-prefix)
(if (not (looking-at "(\\*")) (goto-char (match-end 0)))
(skip-chars-forward " \t\n")
(while (looking-at "(\\*")
(forward-char)
(caml-forward-comment)
(skip-chars-forward " \t\n"))
(if (<= pos (point)) (setq in-expr nil))))
(goto-char pos)
in-expr))
(defun caml-at-sexp-close-p ()
"Check if at end of sexp."
(or (char-equal ?\) (following-char))
(char-equal ?\] (following-char))
(char-equal ?\} (following-char))))
(defun caml-find-kwop (kwop-regexp &optional min-pos)
"Look back for a caml keyword or operator matching KWOP-REGEXP.
Second optional argument MIN-POS bounds the search.
Ignore occurrences inside literals. If found, return a list of two
values: the actual text of the keyword or operator, and a boolean
indicating whether the keyword was one we looked for explicitly
{non-nil}, or on the other hand one of the block-terminating
keywords."
(let ((start-literal (concat "[\"" caml-quote-char "]"))
found kwop)
(while (and (> (point) 1) (not found)
(re-search-backward kwop-regexp min-pos 'move))
(setq kwop (caml-match-string 0))
(cond
((looking-at "(\\*")
(if (> (point) 1) (backward-char)))
((caml-in-comment-p)
(search-backward "(" min-pos 'move))
((looking-at start-literal))
((caml-in-literal-p)
(re-search-backward start-literal min-pos 'move)) ;ugly hack
((setq found t))))
(if found
(if (not (string-match "\\`[^|[]|[^]|]?\\'" kwop)) ;arrrrgh!!
kwop
(forward-char 1) "|")
nil)))
; Association list of indentation values based on governing keywords.
;
;Each element is of the form (KEYWORD OP-TYPE PRIO INDENT). OP-TYPE is
;non-nil for operator-type nodes, which affect indentation in a
;different way from keywords: subsequent lines are indented to the
;actual occurrence of an operator, but relative to the indentation of
;the line where the governing keyword occurs.
(defconst caml-no-indent 0)
(defconst caml-kwop-alist
'(("begin" nil 6 caml-begin-indent)
(":begin" nil 6 caml-begin-indent) ; hack
("class" nil 0 caml-class-indent)
("constraint" nil 0 caml-val-indent)
("sig" nil 1 caml-sig-indent)
("struct" nil 1 caml-struct-indent)
("exception" nil 0 caml-exception-indent)
("for" nil 6 caml-for-indent)
("fun" nil 3 caml-fun-indent)
("function" nil 3 caml-function-indent)
("if" nil 6 caml-if-indent)
("if-else" nil 6 caml-if-else-indent)
("include" nil 0 caml-include-indent)
("inherit" nil 0 caml-inherit-indent)
("initializer" nil 0 caml-initializer-indent)
("let" nil 6 caml-let-indent)
("let-in" nil 6 caml-let-in-indent)
("match" nil 6 caml-match-indent)
("method" nil 0 caml-method-indent)
("module" nil 0 caml-module-indent)
("object" nil 6 caml-object-indent)
("of" nil 7 caml-of-indent)
("open" nil 0 caml-no-indent)
("parser" nil 3 caml-parser-indent)
("try" nil 6 caml-try-indent)
("type" nil 0 caml-type-indent)
("val" nil 0 caml-val-indent)
("when" nil 2 caml-if-indent)
("while" nil 6 caml-while-indent)
("::" t 5 caml-::-indent)
("@" t 4 caml-@-indent)
("^" t 4 caml-@-indent)
(":=" nil 3 caml-:=-indent)
("<-" nil 3 caml-<--indent)
("->" nil 2 caml-->-indent)
("\[" t 8 caml-lb-indent)
("{" t 8 caml-lc-indent)
("\(" t 8 caml-lp-indent)
("|" nil 2 caml-no-indent)
(";;" nil 0 caml-no-indent))
; if-else and let-in are not keywords but idioms
; "|" is not in the regexps
; all these 3 values correspond to hard-coded names
"Association list of indentation values based on governing keywords.
Each element is of the form (KEYWORD OP-TYPE PRIO INDENT). OP-TYPE is
non-nil for operator-type nodes, which affect indentation in a
different way from keywords: subsequent lines are indented to the
actual occurrence of an operator, but relative to the indentation of
the line where the governing keyword occurs.")
;;Originally, we had caml-kwop-regexp create these at runtime, from an
;;additional field in caml-kwop-alist. That proved way too slow,
;;although I still can't understand why. itz
(aset caml-kwop-regexps 0
(concat
"\\_<\\(begin\\|object\\|for\\|s\\(ig\\|truct\\)\\|while\\)\\_>"
"\\|:begin\\>\\|[[({]\\|;;"))
(aset caml-kwop-regexps 1
(concat (aref caml-kwop-regexps 0) "\\|\\_<\\(class\\|module\\)\\_>"))
(aset caml-kwop-regexps 2
(concat
(aref caml-kwop-regexps 1)
"\\|\\_<\\(fun\\(ction\\)?\\|initializer\\|let\\|m\\(atch\\|ethod\\)"
"\\|parser\\|try\\|val\\)\\_>\\|->"))
(aset caml-kwop-regexps 3
(concat (aref caml-kwop-regexps 2) "\\|\\_<if\\|when\\_>"))
(aset caml-kwop-regexps 4
(concat (aref caml-kwop-regexps 3) "\\|:=\\|<-"))
(aset caml-kwop-regexps 5
(concat (aref caml-kwop-regexps 4) "\\|@"))
(aset caml-kwop-regexps 6
(concat (aref caml-kwop-regexps 5) "\\|::\\|\\^"))
(aset caml-kwop-regexps 7
(concat
(aref caml-kwop-regexps 0)
"\\|\\_<\\(constraint\\|exception\\|in\\(herit\\|clude\\)"
"\\|o\\(f\\|pen\\)\\|type\\|val\\)\\_>"))
(aset caml-kwop-regexps 8
(concat (aref caml-kwop-regexps 6)
"\\|\\_<\\(constraint\\|exception\\|in\\(herit\\|clude\\)"
"\\|o\\(f\\|pen\\)\\|type\\)\\>"))
(defun caml-find-done-match ()
"Move the point to the begining of a loop.
Return whether it is \"for\" or \"while\"."
(let ((unbalanced 1) (kwop t))
(while (and (not (= 0 unbalanced)) kwop)
(setq kwop (caml-find-kwop "\\_<\\(done\\|for\\|while\\)\\_>"))
(cond
((not kwop))
((string= kwop "done") (setq unbalanced (1+ unbalanced)))
(t (setq unbalanced (1- unbalanced)))))
kwop))
(defun caml-find-end-match ()
"Move the point at the beginning of a block closed by \"end\".
Return the keyword \"begin\", \"object\", \"sig\", \"struct\"
indicating the type of block."
(let ((unbalanced 1) (kwop t))
(while (and (not (= 0 unbalanced)) kwop)
(setq kwop
(caml-find-kwop
"\\_<\\(end\\|begin\\|object\\|s\\(ig\\|truct\\)\\)\\_>\\|:begin\\_>\\|;;"))
(cond
((not kwop))
((string= kwop ";;") (setq kwop nil) (forward-line 1))
((string= kwop "end") (setq unbalanced (1+ unbalanced)))
( t (setq unbalanced (1- unbalanced)))))
(if (string= kwop ":begin") "begin"
kwop)))
(defun caml-find-in-match ()
"Move the point backward to the \"let\" binding the current expression."
(let ((unbalanced 1) (kwop t))
(while (and (not (= 0 unbalanced)) kwop)
(setq kwop (caml-find-kwop "\\_<\\(in\\|let\\|end\\)\\_>"))
(cond
((not kwop))
((string= kwop "end") (caml-find-end-match))
((string= kwop "in") (setq unbalanced (1+ unbalanced)))
(t (setq unbalanced (1- unbalanced)))))
kwop))
(defun caml-find-with-match ()
"Move the point backward to the keyword starting the current \"with\"."
(let ((unbalanced 1) (kwop t))
(while (and (not (= 0 unbalanced)) kwop)
(setq kwop
(caml-find-kwop
"\\_<\\(with\\|try\\|m\\(atch\\|odule\\)\\|functor\\)\\_>\\|[{}()]"))
(cond
((not kwop))
((caml-at-sexp-close-p)
(caml-find-paren-match (following-char)))
((string= kwop "with")
(setq unbalanced (1+ unbalanced)))
((or (string= kwop "module")
(string= kwop "functor")
(string= kwop "{")
(string= kwop "("))
(setq unbalanced 0))
(t (setq unbalanced (1- unbalanced)))))
kwop))
(defun caml-find-paren-match (close)
"Move the point backward to the opening parenthesis of the current expr.
Which parenthesis is determined by providing the closing one as CLOSE."
(let ((unbalanced 1)
(regexp (cond ((= close ?\)) "[()]")
((= close ?\]) "[][]")
((= close ?\}) "[{}]"))))
(while (and (> unbalanced 0)
(caml-find-kwop regexp))
(if (= close (following-char))
(setq unbalanced (1+ unbalanced))
(setq unbalanced (1- unbalanced))))))
(defun caml-find-then-match (&optional from-else)
"Move the point backward to the \"if\" of the current \"then\".
Assumes the point is at the beginning of the \"then\" keyword unless
FROM-ELSE is non-nil in which case the point must be before \"else\"."
(let ((bol (if from-else
(save-excursion
(progn (beginning-of-line) (point)))))
kwop done matching-fun)
(while (not done)
(setq kwop
(caml-find-kwop
"\\_<\\(e\\(nd\\|lse\\)\\|done\\|then\\|if\\|with\\)\\_>\\|[])};]"))
(cond
((not kwop) (setq done t))
((caml-at-sexp-close-p)
(caml-find-paren-match (following-char)))
((string= kwop "if") (setq done t))
((string= kwop "then")
(if (not from-else) (setq kwop (caml-find-then-match))))
((setq matching-fun (cdr-safe (assoc kwop caml-matching-kw-alist)))
(setq kwop (funcall matching-fun)))))
(if (and bol (>= (point) bol))
"if-else"
kwop)))
(defun caml-find-pipe-match ()
(let ((done nil) (kwop)
(re (concat
"\\_<\\(try\\|match\\|with\\|function\\|parser\\|type"
"\\|e\\(nd\\|lse\\)\\|done\\|then\\|in\\)\\_>"
"\\|[^[|]|\\|[])}]")))
(while (not done)
(setq kwop (caml-find-kwop re))
(cond
((not kwop) (setq done t))
((looking-at "[^[|]\\(|\\)")
(goto-char (match-beginning 1))
(setq kwop "|")
(setq done t))
((caml-at-sexp-close-p)
(caml-find-paren-match (following-char)))
((string= kwop "with")
(setq kwop (caml-find-with-match))
(setq done t))
((string= kwop "parser")
(if (re-search-backward "\\_<with\\_>" (- (point) 5) t)
(setq kwop (caml-find-with-match)))
(setq done t))
((string= kwop "done") (caml-find-done-match))
((string= kwop "end") (caml-find-end-match))
((string= kwop "then") (caml-find-then-match))
((string= kwop "else") (caml-find-else-match))
((string= kwop "in") (caml-find-in-match))
(t (setq done t))))
kwop))
(defun caml-find-and-match ()
(let ((done nil) (kwop))
(while (not done)
(setq kwop (caml-find-kwop
"\\_<\\(object\\|exception\\|let\\|type\\|end\\|in\\)\\_>"))
(cond
((not kwop) (setq done t))
((string= kwop "end") (caml-find-end-match))
((string= kwop "in") (caml-find-in-match))
(t (setq done t))))
kwop))
(defun caml-find-else-match ()
(caml-find-then-match t))
(defun caml-find-semi-match ()
(caml-find-kwop-skipping-blocks 2))
(defun caml-find-comma-match ()
(caml-find-kwop-skipping-blocks 3))
(defun caml-find-kwop-skipping-blocks (prio)
"Look back for a caml keyword matching `caml-kwop-regexps' [PRIO].
Skip nested blocks."
(let ((done nil) (kwop nil) (matching-fun)
(kwop-list (aref caml-kwop-regexps prio)))
(while (not done)
(setq kwop (caml-find-kwop
(concat caml-matching-kw-regexp
(cond ((> prio 3) "\\|[])},;]\\|")
((> prio 2) "\\|[])};]\\|")
(t "\\|[])}]\\|"))
kwop-list)))
(cond
((not kwop) (setq done t))
((caml-at-sexp-close-p)
(caml-find-paren-match (following-char)))
((or (string= kwop ";;")
(and (string= kwop ";") (= (preceding-char) ?\;)))
(forward-line 1)
(setq kwop ";;")
(setq done t))
((and (>= prio 2) (string= kwop "|")) (setq done t))
((string= kwop "end") (caml-find-end-match))
((string= kwop "done") (caml-find-done-match))
((string= kwop "in")
(cond ((and (caml-find-in-match) (>= prio 2))
(setq kwop "let-in")
(setq done t))))
((and (string= kwop "parser") (>= prio 2)
(re-search-backward "\\_<with\\_>" (- (point) 5) t))
(setq kwop (caml-find-with-match))
(setq done t))
((setq matching-fun (cdr-safe (assoc kwop caml-matching-kw-alist)))
(setq kwop (funcall matching-fun))
(if (looking-at kwop-list) (setq done t)))
(t (let* ((kwop-info (assoc kwop caml-kwop-alist))
(is-op (and (nth 1 kwop-info)
; check that we are not at beginning of line
(let ((pos (point)) bti)
(back-to-indentation)
(setq bti (point))
(goto-char pos)
(< bti pos)))))
(if (and is-op (looking-at
(concat (regexp-quote kwop)
"|?[ \t]*\\(\n\\|(\\*\\)")))
(setq kwop-list
(aref caml-kwop-regexps (nth 2 kwop-info)))
(setq done t))))))
kwop))
(defun caml-compute-basic-indent (prio)
"Compute indent of current caml line, ignoring leading keywords.
Find the `governing node' for current line. Compute desired
indentation based on the node and the indentation alists.
Assumes point is exactly at line indentation.
Does not preserve point."
(let* (in-expr
(kwop (cond
((looking-at ";;")
(beginning-of-line 1))
((looking-at "|\\([^]|]\\|\\'\\)")
(caml-find-pipe-match))
((and (looking-at caml-phrase-start-keywords)
(caml-in-expr-p))
(caml-find-end-match))
((and (looking-at caml-matching-kw-regexp)
(assoc (caml-match-string 0) caml-matching-kw-alist))
(funcall (cdr-safe (assoc (caml-match-string 0)
caml-matching-kw-alist))))
((looking-at
(aref caml-kwop-regexps caml-max-indent-priority))
(let* ((kwop (caml-match-string 0))
(kwop-info (assoc kwop caml-kwop-alist))
(prio (if kwop-info (nth 2 kwop-info)
caml-max-indent-priority)))
(if (and (looking-at (aref caml-kwop-regexps 0))
(not (looking-at "object"))
(caml-in-expr-p))
(setq in-expr t))
(caml-find-kwop-skipping-blocks prio)))
(t
(if (and (= prio caml-max-indent-priority) (caml-in-expr-p))
(setq in-expr t))
(caml-find-kwop-skipping-blocks prio))))
(kwop-info (assoc kwop caml-kwop-alist))
(indent-diff
(cond
((not kwop-info) (beginning-of-line 1) 0)
((looking-at "[[({][|<]?[ \t]*")
(length (caml-match-string 0)))
((nth 1 kwop-info) (symbol-value (nth 3 kwop-info)))
(t
(let () ;; (pos (point))
(back-to-indentation)
; (if (looking-at "\\_<let\\_>") (goto-char pos))
(- (symbol-value (nth 3 kwop-info))
(if (looking-at "|") caml-|-extra-indent 0))))))
(extra (if in-expr caml-apply-extra-indent 0)))
(+ indent-diff extra (current-column))))
(defconst caml-leading-kwops-regexp
(concat
"\\_<\\(and\\|do\\(ne\\|wnto\\)?\\|e\\(lse\\|nd\\)\\|in"
"\\|t\\(hen\\|o\\)\\|with\\)\\_>\\|[]|})]")
"Regexp matching caml keywords which need special indentation.")
(defconst caml-leading-kwops-alist
'(("and" caml-and-extra-indent 2)
("do" caml-do-extra-indent 0)
("done" caml-done-extra-indent 0)
("else" caml-else-extra-indent 3)
("end" caml-end-extra-indent 0)
("in" caml-in-extra-indent 2)
("then" caml-then-extra-indent 3)
("to" caml-to-extra-indent 0)
("downto" caml-to-extra-indent 0)
("with" caml-with-extra-indent 2)
("|" caml-|-extra-indent 2)
("]" caml-rb-extra-indent 0)
("}" caml-rc-extra-indent 0)
(")" caml-rp-extra-indent 0))
"Association list of special caml keyword indent values.
Each member is of the form (KEYWORD EXTRA-INDENT PRIO) where
EXTRA-INDENT is the variable holding extra indentation amount for
KEYWORD (usually negative) and PRIO is upper bound on priority of
matching nodes to determine KEYWORD's final indentation.")
(defun caml-compute-final-indent ()
(save-excursion
(back-to-indentation)
(cond
((and (bolp) (looking-at comment-start-skip)) (current-column))
((caml-in-comment-p)
(let ((closing (looking-at "\\*)"))
(comment-mark (looking-at "\\*")))
(caml-backward-comment)
(looking-at comment-start-skip)
(+ (current-column)
(cond
(closing 1)
(comment-mark 1)
(t (- (match-end 0) (match-beginning 0)))))))
(t (let* ((leading (looking-at caml-leading-kwops-regexp))
(assoc-val (if leading (assoc (caml-match-string 0)
caml-leading-kwops-alist)))
(extra (if leading (symbol-value (nth 1 assoc-val)) 0))
(prio (if leading (nth 2 assoc-val)
caml-max-indent-priority))
(basic (caml-compute-basic-indent prio)))
(max 0 (if extra (+ extra basic) (current-column))))))))
(defun caml-split-string ()
"Called whenever a line is broken inside a caml string literal."
(insert-before-markers "\"^\"")
(backward-char 1))
(defadvice indent-new-comment-line (around
caml-indent-new-comment-line
activate)
"Handle multi-line strings in caml mode."
;this advice doesn't make sense in other modes. I wish there were a
;cleaner way to do this: I haven't found one.
(let ((hooked (and (eq major-mode 'caml-mode) (caml-in-literal-p)))
(split-mark))
(if (not hooked) nil
(setq split-mark (set-marker (make-marker) (point)))
(caml-split-string))
ad-do-it
(if (not hooked) nil
(goto-char split-mark)
(set-marker split-mark nil))))
(defadvice newline-and-indent (around
caml-newline-and-indent
activate)
"Handle multi-line strings in caml mode."
(let ((hooked (and (eq major-mode 'caml-mode) (caml-in-literal-p)))
(split-mark))
(if (not hooked) nil
(setq split-mark (set-marker (make-marker) (point)))
(caml-split-string))
ad-do-it
(if (not hooked) nil
(goto-char split-mark)
(set-marker split-mark nil))))
(defun caml-electric-pipe ()
"If inserting a | or } operator at beginning of line, reindent the line.
Unfortunately there is a situation where this mechanism gets
confused. It's when | is the first character of a |] sequence. This is
a misfeature of caml syntax and cannot be fixed, however, as a
workaround, the electric ] inserts | itself if the matching [ is
followed by |."
(interactive "*")
(let ((electric (and caml-electric-indent
(caml-in-indentation)
(not (caml-in-comment-p)))))
(self-insert-command 1)
(if electric (save-excursion (caml-indent-command)))))
(defun caml-electric-rb ()
"If inserting a ] operator at beginning of line, reindent the line.
Also, if the matching [ is followed by a | and this ] is not preceded
by |, insert one."
(interactive "*")
(let* ((prec (preceding-char))
(use-pipe (and caml-electric-close-vector
(not (caml-in-comment-p))
(not (caml-in-literal-p))
(or (not (numberp prec))
(not (char-equal ?| prec)))))
(electric (and caml-electric-indent
(caml-in-indentation)
(not (caml-in-comment-p)))))
(self-insert-command 1)
(if electric (save-excursion (caml-indent-command)))
(if (and use-pipe
(save-excursion
(condition-case nil
(prog2
(backward-list 1)
(looking-at "\\[|"))
(error ""))))
(save-excursion
(backward-char 1)
(insert "|")))))
(defun caml-abbrev-hook ()
"If inserting a leading keyword at beginning of line, reindent the line."
;itz unfortunately we need a special case
(if (and (not (caml-in-comment-p)) (not (= last-command-event ?_)))
(let* ((bol (save-excursion (beginning-of-line) (point)))
(kw (save-excursion
(and (re-search-backward "^[ \t]*\\(\\sw+\\)\\=" bol t)
(caml-match-string 1)))))
(if kw
(let ((indent (save-excursion
(goto-char (match-beginning 1))
(caml-indent-command)
(current-column)))
(abbrev-correct (if (= last-command-event ?\ ) 1 0)))
(indent-to (- indent
(or
(symbol-value
(nth 1
(assoc kw caml-leading-kwops-alist)))
0)
abbrev-correct)))))))
; (defun caml-indent-phrase ()
; (interactive "*")
; (let ((bounds (caml-mark-phrase)))
; (indent-region (car bounds) (cdr bounds) nil)))
;;; Additional commands by Didier to report errors in toplevel mode
(defun caml-skip-blank-forward ()
(if (looking-at "[ \t\n]*\\((\\*\\([^*]\\|[^(]\\*[^)]\\)*\\*)[ \t\n]*\\)*")
(goto-char (match-end 0))))
;; to mark phrases, so that repeated calls will take several of them
;; knows little about OCaml except literals and comments, so it should work
;; with other dialects as long as ;; marks the end of phrase.
(defun caml-indent-phrase (arg)
"Indent the current phrase.
With prefix ARG, indent that many phrases starting with the
current phrase."
(interactive "p")
(save-excursion
(let ((beg (caml-find-phrase)))
(while (progn (setq arg (- arg 1)) (> arg 0)) (caml-find-phrase))
(indent-region beg (point) nil))))
(defun caml-indent-buffer ()
"Indent the whole buffer."
(interactive)
(indent-region (point-min) (point-max) nil))
(defun caml-backward-to-less-indent (&optional n)
"Move cursor back N lines with less or same indentation."
(interactive "p")
(beginning-of-line 1)
(if (< n 0) (caml-forward-to-less-indent (- n))
(while (> n 0)
(let ((i (current-indentation)))
(forward-line -1)
(while (or (> (current-indentation) i)
(caml-in-comment-p)
(looking-at
(concat "[ \t]*\\(\n\\|" comment-start-skip "\\)")))
(forward-line -1)))
(setq n (1- n))))
(back-to-indentation))
(defun caml-forward-to-less-indent (&optional n)
"Move cursor back N lines with less or same indentation."
(interactive "p")
(beginning-of-line 1)
(if (< n 0) (caml-backward-to-less-indent (- n))
(while (> n 0)
(let ((i (current-indentation)))
(forward-line 1)
(while (or (> (current-indentation) i)
(caml-in-comment-p)
(looking-at
(concat "[ \t]*\\(\n\\|" comment-start-skip "\\)")))
(forward-line 1)))
(setq n (1- n))))
(back-to-indentation))
(defun caml-insert-begin-form ()
"Insert a nicely formatted begin-end form, leaving a mark after end."
(interactive "*")
(let ((prec (preceding-char)))
(if (and (numberp prec) (not (char-equal ?\ (char-syntax prec))))
(insert " ")))
(let* ((c (current-indentation)) (i (+ caml-begin-indent c)))
(insert "begin\n\nend")
(push-mark)
(indent-line-to c)
(forward-line -1)
(indent-line-to i)))
(defun caml-insert-for-form ()
"Insert a nicely formatted for-do-done form, leaving a mark after do(ne)."
(interactive "*")
(let ((prec (preceding-char)))
(if (and (numberp prec) (not (char-equal ?\ (char-syntax prec))))
(insert " ")))
(let* ((c (current-indentation)) (i (+ caml-for-indent c)))
(insert "for do\n\ndone")
(push-mark)
(indent-line-to c)
(forward-line -1)
(indent-line-to i)
(push-mark)
(beginning-of-line 1)
(backward-char 4)))
(defun caml-insert-if-form ()
"Insert nicely formatted if-then-else form leaving mark after then, else."
(interactive "*")
(let ((prec (preceding-char)))
(if (and (numberp prec) (not (char-equal ?\ (char-syntax prec))))
(insert " ")))
(let* ((c (current-indentation)) (i (+ caml-if-indent c)))
(insert "if\n\nthen\n\nelse\n")
(indent-line-to i)
(push-mark)
(forward-line -1)
(indent-line-to c)
(forward-line -1)
(indent-line-to i)
(push-mark)
(forward-line -1)
(indent-line-to c)
(forward-line -1)
(indent-line-to i)))
(defun caml-insert-match-form ()
"Insert nicely formatted match-with form leaving mark after with."
(interactive "*")
(let ((prec (preceding-char)))
(if (and (numberp prec) (not (char-equal ?\ (char-syntax prec))))
(insert " ")))
(let* ((c (current-indentation)) (i (+ caml-match-indent c)))
(insert "match\n\nwith\n")
(indent-line-to i)
(push-mark)
(forward-line -1)
(indent-line-to c)
(forward-line -1)
(indent-line-to i)))
(defun caml-insert-let-form ()
"Insert nicely formatted let-in form leaving mark after in."
(interactive "*")
(let ((prec (preceding-char)))
(if (and (numberp prec) (not (char-equal ?\ (char-syntax prec))))
(insert " ")))
(let* ((c (current-indentation)))
(insert "let in\n")
(indent-line-to c)
(push-mark)
(forward-line -1)
(forward-char (+ c 4))))
(defun caml-insert-try-form ()
"Insert nicely formatted try-with form leaving mark after with."
(interactive "*")
(let ((prec (preceding-char)))
(if (and (numberp prec) (not (char-equal ?\ (char-syntax prec))))
(insert " ")))
(let* ((c (current-indentation)) (i (+ caml-try-indent c)))
(insert "try\n\nwith\n")
(indent-line-to i)
(push-mark)
(forward-line -1)
(indent-line-to c)
(forward-line -1)
(indent-line-to i)))
(defun caml-insert-while-form ()
"Insert nicely formatted while-do-done form leaving mark after do, done."
(interactive "*")
(let ((prec (preceding-char)))
(if (and (numberp prec) (not (char-equal ?\ (char-syntax prec))))
(insert " ")))
(let* ((c (current-indentation)) (i (+ caml-if-indent c)))
(insert "while do\n\ndone")
(push-mark)
(indent-line-to c)
(forward-line -1)
(indent-line-to i)
(push-mark)
(beginning-of-line 1)
(backward-char 4)))
(autoload 'run-caml "inf-caml" "Run an inferior OCaml process." t)
(autoload 'caml-types-show-type "caml-types"
"Show the type of expression or pattern at point." t)
(autoload 'caml-types-explore "caml-types"
"Explore type annotations by mouse dragging." t)
(autoload 'caml-help "caml-help"
"Show documentation for qualified OCaml identifier." t)
(autoload 'caml-complete "caml-help"
"Does completion for documented qualified OCaml identifier." t)
(autoload 'ocaml-open-module "caml-help"
"Add module in documentation search path." t)
(autoload 'ocaml-close-module "caml-help"
"Remove module from documentation search path." t)
(autoload 'ocaml-add-path "caml-help"
"Add search path for documentation." t)
(provide 'caml)
;;; caml.el ends here
;************************************************************************** -*- lexical-binding: t; -*-
;* *
;* OCaml *
;* *
;* Damien Doligez, projet Moscova, INRIA Rocquencourt *
;* *
;* Copyright 2003 Institut National de Recherche en Informatique et *
;* en Automatique. *
;* *
;* All rights reserved. This file is distributed under the terms of *
;* the GNU General Public License. *
;* *
;**************************************************************************
; An emacs-lisp complement to the "-annot" option of ocamlc and ocamlopt.
(defvar caml-types-buffer) ;Forward declaration.
(defun caml-types-feedback (info format)
"Displays INFO using the given FORMAT."
(message (format format info))
(with-current-buffer caml-types-buffer
(erase-buffer)
(insert info)))
(defvar caml-types-build-dirs '("_build" "_obuild")
"List of possible compilation directories created by build systems.
It is expected that the files under `caml-types-build-dir' preserve
the paths relative to the parent directory of `caml-types-build-dir'.")
(make-variable-buffer-local 'caml-types-build-dir)
(defvar caml-annot-dir nil
"A directory, generally relative to the file location, containing the
.annot file. Intended to be set as a local variable in the .ml file.
See \"Specifying File Variables\" in the Emacs info manual.")
(make-variable-buffer-local 'caml-annot-dir)
(put 'caml-annot-dir 'safe-local-variable #'stringp)
(defvar caml-types-location-re nil "Regexp to parse *.annot files.
Annotation files *.annot may be generated with the \"-annot\" option
of ocamlc and ocamlopt.
Their format is:
file ::= block *
block ::= position <SP> position <LF> annotation *
position ::= filename <SP> num <SP> num <SP> num
annotation ::= keyword open-paren <LF> <SP> <SP> data <LF> close-paren <LF>
<SP> is a space character (ASCII 0x20)
<LF> is a line-feed character (ASCII 0x0A)
num is a sequence of decimal digits
filename is a string with the lexical conventions of OCaml
open-paren is an open parenthesis (ASCII 0x28)
close-paren is a closed parenthesis (ASCII 0x29)
data is any sequence of characters where <LF> is always followed by
at least two space characters.
- in each block, the two positions are respectively the start and the
end of the range described by the block.
- in a position, the filename is the name of the file, the first num
is the line number, the second num is the offset of the beginning
of the line, the third num is the offset of the position itself.
- the char number within the line is the difference between the third
and second nums.
The current list of keywords is:
type call ident")
(defvar caml-types-position-re nil)
(let* ((caml-types-filename-re "\"\\(\\([^\\\"]\\|\\\\.\\)*\\)\"")
(caml-types-number-re "\\([0-9]*\\)"))
(setq caml-types-position-re
(concat caml-types-filename-re " "
caml-types-number-re " "
caml-types-number-re " "
caml-types-number-re))
(setq caml-types-location-re
(concat "^" caml-types-position-re " " caml-types-position-re)))
(defgroup caml-types nil
"Customization for `caml-types'."
:group 'languages)
(defface caml-types-expr-face
'((((class color) (background light)) :background "#88FF44")
(((class color) (background dark)) :background "dark green"))
"Face for highlighting expressions and types")
(defvar caml-types-expr-ovl (make-overlay 1 1))
(overlay-put caml-types-expr-ovl 'face 'caml-types-expr-face)
(defface caml-types-typed-face '((t :background "#FF8844"))
"Face for highlighting typed expressions.")
(defvar caml-types-typed-ovl (make-overlay 1 1))
(overlay-put caml-types-typed-ovl 'face 'caml-types-typed-face)
(defface caml-types-scope-face
'((((class color) (background light)) :background "#BBFFFF")
(((class color) (background dark)) :background "dark blue"))
"Face for highlighting variable scopes.")
(defvar caml-types-scope-ovl (make-overlay 1 1))
(overlay-put caml-types-scope-ovl 'face 'caml-types-scope-face)
(defface caml-types-def-face '((t :background "#FF4444"))
"Face for highlighting binding occurrences.")
(defvar caml-types-def-ovl (make-overlay 1 1))
(overlay-put caml-types-def-ovl 'face 'caml-types-def-face)
(defface caml-types-occ-face
'((((class color) (background light)) :background "#44FF44")
(((class color) (background dark)) :background "dark green"))
"Face for highlighting variable occurrences.")
(defvar caml-types-occ-ovl (make-overlay 1 1))
(overlay-put caml-types-occ-ovl 'face 'caml-types-occ-face)
(defvar caml-types-annotation-tree nil)
(defvar caml-types-annotation-date nil)
(make-variable-buffer-local 'caml-types-annotation-tree)
(make-variable-buffer-local 'caml-types-annotation-date)
(defvar caml-types-buffer-name "*caml-types*"
"Name of buffer for displaying caml types.")
(defvar caml-types-buffer nil
"Buffer for displaying caml types.")
(defvar target-file) ;; FIXME: Get rid of this dynbound variable!
(defun caml-types-show-type (arg)
"Show the type of expression or pattern at point.
The smallest expression or pattern that contains point is
temporarily highlighted. Its type is highlighted in the .annot
file and the mark is set to the beginning of the type. The type
is also displayed in the mini-buffer.
Hints on using the type display:
. If you want the type of an identifier, put point within any
occurrence of this identifier.
. If you want the result type of a function application, put
point at the first space after the function name. . If you want
the type of a list, put point on a bracket, on a semicolon, or on
the :: constructor.
. Even if type checking fails, you can still look at the types
in the file, up to where the type checker failed.
Types are also displayed in the buffer *caml-types*, which is
displayed when the command is called with Prefix argument 4.
See also `caml-types-explore' for exploration by mouse dragging.
See `caml-types-location-re' for annotation file format."
(interactive "p")
(let* ((target-buf (current-buffer))
(target-file (file-name-nondirectory (buffer-file-name)))
(target-line (1+ (count-lines (point-min)
(line-beginning-position))))
(target-bol (line-beginning-position))
(target-cnum (point)))
(caml-types-preprocess (buffer-file-name))
(setq caml-types-buffer (get-buffer-create caml-types-buffer-name))
(let* ((targ-loc (vector target-file target-line target-bol target-cnum))
(node (caml-types-find-location targ-loc "type" ()
caml-types-annotation-tree)))
(cond
((null node)
(delete-overlay caml-types-expr-ovl)
(message "Point is not within a typechecked expression or pattern."))
(t
(let ((left (caml-types-get-pos target-buf (elt node 0)))
(right (caml-types-get-pos target-buf (elt node 1)))
(type (cdr (assoc "type" (elt node 2)))))
(move-overlay caml-types-expr-ovl left right target-buf)
(caml-types-feedback type "type: %s")))))
(if (and (= arg 4)
(not (window-live-p (get-buffer-window caml-types-buffer))))
(display-buffer caml-types-buffer))
(unwind-protect
(sit-for 60)
(delete-overlay caml-types-expr-ovl))))
(defun caml-types-show-call (arg)
"Show the kind of call at point.
The smallest function call that contains point is temporarily
highlighted. Its kind is highlighted in the .annot file and the
mark is set to the beginning of the kind. The kind is also
displayed in the mini-buffer.
The kind is also displayed in the buffer *caml-types*, which is
displayed when the command is called with Prefix argument 4.
See `caml-types-location-re' for annotation file format."
(interactive "p")
(let* ((target-buf (current-buffer))
(target-file (file-name-nondirectory (buffer-file-name)))
(target-line (1+ (count-lines (point-min)
(line-beginning-position))))
(target-bol (line-beginning-position))
(target-cnum (point)))
(caml-types-preprocess (buffer-file-name))
(setq caml-types-buffer (get-buffer-create caml-types-buffer-name))
(let* ((targ-loc (vector target-file target-line target-bol target-cnum))
(node (caml-types-find-location targ-loc "call" ()
caml-types-annotation-tree)))
(cond
((null node)
(delete-overlay caml-types-expr-ovl)
(message "Point is not within a function call."))
(t
(let ((left (caml-types-get-pos target-buf (elt node 0)))
(right (caml-types-get-pos target-buf (elt node 1)))
(kind (cdr (assoc "call" (elt node 2)))))
(move-overlay caml-types-expr-ovl left right target-buf)
(caml-types-feedback kind "%s call")))))
(if (and (= arg 4)
(not (window-live-p (get-buffer-window caml-types-buffer))))
(display-buffer caml-types-buffer))
(unwind-protect
(sit-for 60)
(delete-overlay caml-types-expr-ovl))))
(defun caml-types-show-ident (arg)
"Show the binding of identifier at point.
The identifier that contains point is temporarily highlighted.
Its binding is highlighted in the .annot file and the mark is set
to the beginning of the binding. The binding is also displayed
in the mini-buffer.
The binding is also displayed in the buffer *caml-types*, which is
displayed when the command is called with Prefix argument 4.
See `caml-types-location-re' for annotation file format."
(interactive "p")
(let* ((target-buf (current-buffer))
(target-file (file-name-nondirectory (buffer-file-name)))
(target-line (1+ (count-lines (point-min)
(line-beginning-position))))
(target-bol (line-beginning-position))
(target-cnum (point)))
(caml-types-preprocess (buffer-file-name))
(setq caml-types-buffer (get-buffer-create caml-types-buffer-name))
(let* ((targ-loc (vector target-file target-line target-bol target-cnum))
(node (caml-types-find-location targ-loc "ident" ()
caml-types-annotation-tree)))
(cond
((null node)
(delete-overlay caml-types-expr-ovl)
(message "Point is not within an identifier."))
(t
(let ((left (caml-types-get-pos target-buf (elt node 0)))
(right (caml-types-get-pos target-buf (elt node 1)))
(kind (cdr (assoc "ident" (elt node 2)))))
(move-overlay caml-types-expr-ovl left right target-buf)
(let* ((loc-re (concat caml-types-position-re " "
caml-types-position-re))
(end-re (concat caml-types-position-re " --"))
(def-re (concat "def \\([^ ]*\\) " loc-re))
(def-end-re (concat "def \\([^ ]*\\) " end-re))
(internal-re (concat "int_ref \\([^ ]*\\) " loc-re))
(external-re "ext_ref \\(.*\\)"))
(cond
((string-match def-re kind)
(let ((var-name (match-string 1 kind))
(l-file (file-name-nondirectory (match-string 2 kind)))
(l-line (string-to-number (match-string 4 kind)))
(l-bol (string-to-number (match-string 5 kind)))
(l-cnum (string-to-number (match-string 6 kind)))
(r-file (file-name-nondirectory (match-string 7 kind)))
(r-line (string-to-number (match-string 9 kind)))
(r-bol (string-to-number (match-string 10 kind)))
(r-cnum (string-to-number (match-string 11 kind))))
(let* ((lpos (vector l-file l-line l-bol l-cnum))
(rpos (vector r-file r-line r-bol r-cnum))
(left (caml-types-get-pos target-buf lpos))
(right (caml-types-get-pos target-buf rpos)))
(message (format "local variable %s is bound here" var-name))
(move-overlay caml-types-scope-ovl left right target-buf))))
((string-match def-end-re kind)
(let ((var-name (match-string 1 kind))
(l-file (file-name-nondirectory (match-string 2 kind)))
(l-line (string-to-number (match-string 4 kind)))
(l-bol (string-to-number (match-string 5 kind)))
(l-cnum (string-to-number (match-string 6 kind))))
(let* ((lpos (vector l-file l-line l-bol l-cnum))
(left (caml-types-get-pos target-buf lpos))
(right (buffer-size target-buf)))
(message (format "global variable %s is bound here" var-name))
(move-overlay caml-types-scope-ovl left right target-buf))))
((string-match internal-re kind)
(let ((var-name (match-string 1 kind))
(l-file (file-name-nondirectory (match-string 2 kind)))
(l-line (string-to-number (match-string 4 kind)))
(l-bol (string-to-number (match-string 5 kind)))
(l-cnum (string-to-number (match-string 6 kind)))
(r-file (file-name-nondirectory (match-string 7 kind)))
(r-line (string-to-number (match-string 9 kind)))
(r-bol (string-to-number (match-string 10 kind)))
(r-cnum (string-to-number (match-string 11 kind))))
(let* ((lpos (vector l-file l-line l-bol l-cnum))
(rpos (vector r-file r-line r-bol r-cnum))
(left (caml-types-get-pos target-buf lpos))
(right (caml-types-get-pos target-buf rpos)))
(move-overlay caml-types-def-ovl left right target-buf)
(message (format "%s is bound at line %d char %d"
var-name l-line (- l-cnum l-bol))))))
((string-match external-re kind)
(let ((fullname (match-string 1 kind)))
(caml-types-feedback fullname "external ident: %s")))))))))
(if (and (= arg 4)
(not (window-live-p (get-buffer-window caml-types-buffer))))
(display-buffer caml-types-buffer))
(unwind-protect
(sit-for 60)
(delete-overlay caml-types-expr-ovl)
(delete-overlay caml-types-def-ovl)
(delete-overlay caml-types-scope-ovl))))
(defun caml-types-preprocess (target-path)
(let* ((type-path (caml-types-locate-type-file target-path))
(type-date (nth 5 (file-attributes (file-chase-links type-path))))
(target-date (nth 5 (file-attributes target-file))))
(unless (and caml-types-annotation-tree
type-date
caml-types-annotation-date
(not (caml-types-date< caml-types-annotation-date type-date)))
(if (and type-date target-date (caml-types-date< type-date target-date))
(error (format "`%s' is more recent than `%s'"
target-path type-path)))
(message "Reading annotation file...")
(let* ((type-buf (caml-types-find-file type-path))
(tree (with-current-buffer type-buf
(widen)
(goto-char (point-min))
(caml-types-build-tree
(file-name-nondirectory target-path)))))
(setq caml-types-annotation-tree tree
caml-types-annotation-date type-date)
(kill-buffer type-buf)
(message "done")))))
(defun caml-types-parent-dir (d) (file-name-directory (directory-file-name d)))
(defun caml-types-locate-type-file (target-path)
"Given the path to an OCaml file, try to locate and return the
corresponding .annot file."
(let ((sibling (concat (file-name-sans-extension target-path) ".annot")))
(if (file-exists-p sibling)
sibling
(let* ((dir (file-name-directory sibling)))
(if caml-annot-dir
;; Use the relative path set by the user
(let* ((annot-dir (expand-file-name caml-annot-dir dir))
(fname (file-name-nondirectory sibling))
(path-fname (expand-file-name fname annot-dir)))
(if (file-exists-p path-fname)
path-fname
(error (concat "No annotation file in " caml-annot-dir
". Compile with option \"-annot\"."))))
;; Else, try to get the .annot from one of build dirs.
(let* ((is-build (regexp-opt caml-types-build-dirs))
(project-dir (locate-dominating-file
dir
(lambda(d) (directory-files d nil is-build))))
(annot
(if project-dir
(locate-file
(file-relative-name sibling project-dir)
(mapcar (lambda(d) (expand-file-name d project-dir))
caml-types-build-dirs)))))
(if annot
annot
(error (concat "No annotation file. Compile with option "
"\"-annot\" or set `caml-annot-dir'.")))))))))
(defun caml-types-date< (date1 date2)
(or (< (car date1) (car date2))
(and (= (car date1) (car date2))
(< (nth 1 date1) (nth 1 date2)))))
; we use an obarray for hash-consing the strings within each tree
(defun caml-types-make-hash-table ()
(make-vector 255 0))
(defun caml-types-hcons (elem table)
(symbol-name (intern elem table)))
(defun caml-types--next-annotation ()
(forward-char 1)
(if (re-search-forward "^[a-z\"]" () t)
(forward-char -1)
(goto-char (point-max)))
(looking-at "[a-z]"))
; tree of intervals
; each node is a vector
; [ pos-left pos-right annotation child child child... ]
; annotation is a list of:
; (kind . info) where kind = "type" "call" etc.
; and info = the contents of the annotation
(defun caml-types-build-tree (t-file)
(let ((target-file t-file)
(stack ())
(accu ())
(table (caml-types-make-hash-table))
(annotation ()))
(while (re-search-forward caml-types-location-re () t)
(let ((l-file (file-name-nondirectory (match-string 1)))
(l-line (string-to-number (match-string 3)))
(l-bol (string-to-number (match-string 4)))
(l-cnum (string-to-number (match-string 5)))
(r-file (file-name-nondirectory (match-string 6)))
(r-line (string-to-number (match-string 8)))
(r-bol (string-to-number (match-string 9)))
(r-cnum (string-to-number (match-string 10))))
(unless (caml-types-not-in-file l-file r-file target-file)
(setq annotation ())
(while (caml-types--next-annotation)
(cond ((looking-at "^\\([a-z]+\\)(\n \\(\\(.*\n \\)*.*\\)\n)")
(let ((kind (caml-types-hcons (match-string 1) table))
(info (caml-types-hcons (match-string 2) table)))
(setq annotation (cons (cons kind info) annotation))))))
(setq accu ())
(while (and stack
(caml-types-pos-contains l-cnum r-cnum (car stack)))
(setq accu (cons (car stack) accu))
(setq stack (cdr stack)))
(let* ((left-pos (vector l-file l-line l-bol l-cnum))
(right-pos (vector r-file r-line r-bol r-cnum))
(node (caml-types-make-node left-pos right-pos annotation
accu)))
(setq stack (cons node stack))))))
(if (null stack)
(error "No annotations found for this source file")
(let* ((left-pos (elt (car (last stack)) 0))
(right-pos (elt (car stack) 1)))
(if (null (cdr stack))
(car stack)
(caml-types-make-node left-pos right-pos () (nreverse stack)))))))
(defun caml-types-not-in-file (l-file r-file t-file)
(not (and (or (string= l-file t-file)
(string= l-file ""))
(or (string= r-file t-file)
(string= r-file "")))))
(defun caml-types-make-node (left-pos right-pos annotation children)
(let ((result (make-vector (+ 3 (length children)) ()))
(i 3))
(aset result 0 left-pos)
(aset result 1 right-pos)
(aset result 2 annotation)
(while children
(aset result i (car children))
(setq children (cdr children))
(setq i (1+ i)))
result))
(defun caml-types-pos-contains (l-cnum r-cnum node)
(and (<= l-cnum (elt (elt node 0) 3))
(>= r-cnum (elt (elt node 1) 3))))
(defun caml-types-find-location (targ-pos kind curr node)
(if (not (caml-types-pos-inside targ-pos node))
curr
(if (and (elt node 2) (assoc kind (elt node 2)))
(setq curr node))
(let ((i (caml-types-search node targ-pos)))
(if (and (> i 3)
(caml-types-pos-inside targ-pos (elt node (1- i))))
(caml-types-find-location targ-pos kind curr (elt node (1- i)))
curr))))
; trouve le premier fils qui commence apres la position
; ou (length node) si tous commencent avant
(defun caml-types-search (node pos)
(let ((min 3)
(max (length node))
med)
(while (< min max)
(setq med (/ (+ min max) 2))
(if (caml-types-pos<= (elt (elt node med) 0) pos)
(setq min (1+ med))
(setq max med)))
min))
(defun caml-types-pos-inside (pos node)
(let ((left-pos (elt node 0))
(right-pos (elt node 1)))
(and (caml-types-pos<= left-pos pos)
(caml-types-pos> right-pos pos))))
(defun caml-types-find-interval (buf targ-pos node)
(let ((nleft (elt node 0))
(nright (elt node 1))
(left ())
(right ())
i)
(cond
((not (caml-types-pos-inside targ-pos node))
(if (not (caml-types-pos<= nleft targ-pos))
(setq right nleft))
(if (not (caml-types-pos> nright targ-pos))
(setq left nright)))
(t
(setq left nleft
right nright)
(setq i (caml-types-search node targ-pos))
(if (< i (length node))
(setq right (elt (elt node i) 0)))
(if (> i 3)
(setq left (elt (elt node (1- i)) 1)))))
(cons (if left
(caml-types-get-pos buf left)
(with-current-buffer buf (point-min)))
(if right
(caml-types-get-pos buf right)
(with-current-buffer buf (point-max))))))
;; Warning: these comparison functions are not symmetric.
;; The first argument determines the format:
;; when its file component is empty, only the cnum is compared.
(defun caml-types-pos<= (pos1 pos2)
(let ((file1 (elt pos1 0))
(line1 (elt pos1 1))
(bol1 (elt pos1 2))
(cnum1 (elt pos1 3))
(file2 (elt pos2 0))
(line2 (elt pos2 1))
(bol2 (elt pos2 2))
(cnum2 (elt pos2 3)))
(if (string= file1 "")
(<= cnum1 cnum2)
(and (string= file1 file2)
(or (< line1 line2)
(and (= line1 line2)
(<= (- cnum1 bol1) (- cnum2 bol2))))))))
(defun caml-types-pos> (pos1 pos2)
(let ((file1 (elt pos1 0))
(line1 (elt pos1 1))
(bol1 (elt pos1 2))
(cnum1 (elt pos1 3))
(file2 (elt pos2 0))
(line2 (elt pos2 1))
(bol2 (elt pos2 2))
(cnum2 (elt pos2 3)))
(if (string= file1 "")
(> cnum1 cnum2)
(and (string= file1 file2)
(or (> line1 line2)
(and (= line1 line2)
(> (- cnum1 bol1) (- cnum2 bol2))))))))
(defun caml-types-get-pos (buf pos)
(with-current-buffer buf
(save-restriction
(widen)
(goto-char (point-min))
(forward-line (1- (elt pos 1)))
(forward-char (- (elt pos 3) (elt pos 2)))
(point))))
; find-file-read-only-noselect seems to be missing from emacs...
(defun caml-types-find-file (name)
(let (buf)
(cond
((setq buf (get-file-buffer name))
(unless (verify-visited-file-modtime buf)
(if (buffer-modified-p buf)
(find-file-noselect name)
(with-current-buffer buf (revert-buffer t t)))))
((and (file-readable-p name)
(setq buf (find-file-noselect name)))
(with-current-buffer buf (read-only-mode 1)))
(t
(error (format "Can't read the annotation file `%s'" name))))
buf))
(defun caml-types-mouse-ignore (_event)
(interactive "e")
nil)
(defun caml-types-time ()
(if (fboundp 'time-convert)
(mod (car (time-convert nil 1000)) 1000000)
(let ((time (current-time)))
(+ (* (mod (cadr time) 1000) 1000)
(/ (cadr (cdr time)) 1000)))))
(defun caml--release-event-p (original event)
(and (equal (event-basic-type original) (event-basic-type event))
(let ((modifiers (event-modifiers event)))
(or (member 'drag modifiers)
(member 'click modifiers)))))
(defun caml--event-point-end (e) (posn-point (event-end e)))
(defun caml--event-window (e) (posn-window (event-start e)))
(defun caml-types-explore (event)
"Explore type annotations by mouse dragging.
The expression under the mouse is highlighted and its type is displayed
in the minibuffer, until the move is released, much as `caml-types-show-type'.
The function uses two overlays.
. One overlay delimits the largest region whose all subnodes
are well-typed.
. Another overlay delimits the current node under the mouse (whose type
annotation is being displayed)."
(interactive "e")
(set-buffer (window-buffer (caml--event-window event)))
(let* ((target-buf (current-buffer))
(target-file (file-name-nondirectory (buffer-file-name)))
(target-line) (target-bol)
target-pos
limits cnum node mes type
region
(window (caml--event-window event))
target-tree
(speed 100)
(last-time (caml-types-time))
(original-event event))
(select-window window)
(unwind-protect
(progn
(caml-types-preprocess (buffer-file-name))
(setq target-tree caml-types-annotation-tree)
(setq caml-types-buffer (get-buffer-create caml-types-buffer-name))
;; (message "Drag the mouse to explore types")
(unwind-protect
(track-mouse
(while event
(cond
;; we ignore non mouse events
((integer-or-marker-p event))
;; we stop when the original button is released
((caml--release-event-p original-event event)
(setq event nil))
;; we scroll when the motion is outside the window
((and (mouse-movement-p event)
(not (and (equal window (caml--event-window event))
(integer-or-marker-p
(caml--event-point-end event)))))
(let* ((win (window-edges window))
(top (nth 1 win))
(bottom (- (nth 3 win) 1))
mouse
time)
(while (and
(sit-for 0 (/ 500 speed))
(setq time (caml-types-time))
(> (- time last-time) (/ 500 speed))
(setq mouse (cddr (mouse-position)))
(or (< mouse top) (>= mouse bottom)))
(setq last-time time)
(cond
((< mouse top)
(setq speed (- top mouse))
(condition-case nil
(scroll-down 1)
(error (message "Beginning of buffer!"))))
((>= mouse bottom)
(setq speed (+ 1 (- mouse bottom)))
(condition-case nil
(scroll-up 1)
(error (message "End of buffer!")))))
(setq speed (* speed speed)))))
;; main action, when the motion is inside the window
;; or on original button down event
((or (mouse-movement-p event)
(equal original-event event))
(setq cnum (caml--event-point-end event))
(if (and region
(<= (car region) cnum) (< cnum (cdr region)))
;; mouse remains in outer region
nil
;; otherwise, reset the outer region
(setq region
(caml-types-typed-make-overlay
target-buf (posn-point (event-start event)))))
(if
(and limits
(>= cnum (car limits)) (< cnum (cdr limits)))
;; inner region is unchanged
nil
;; recompute the inner region and type annotation
(setq target-bol
(save-excursion
(goto-char cnum) (line-beginning-position))
target-line (1+ (count-lines (point-min)
target-bol))
target-pos
(vector target-file target-line target-bol cnum))
(save-excursion
(setq node (caml-types-find-location target-pos "type" ()
target-tree))
(set-buffer caml-types-buffer)
(erase-buffer)
(cond
((null node)
(delete-overlay caml-types-expr-ovl)
(setq type "*no type information*")
(setq limits
(caml-types-find-interval
target-buf target-pos target-tree)))
(t
(let ((left
(caml-types-get-pos target-buf (elt node 0)))
(right
(caml-types-get-pos target-buf (elt node 1))))
(move-overlay
caml-types-expr-ovl left right target-buf)
(setq limits
(caml-types-find-interval target-buf
target-pos node)
type (cdr (assoc "type" (elt node 2)))))))
(setq mes (format "type: %s" type))
(insert type)))
(message mes)))
;; we read next event, unless it is nil, and loop back.
(if event (setq event (read-event)))))
;; delete overlays at end of exploration
(delete-overlay caml-types-expr-ovl)
(delete-overlay caml-types-typed-ovl)))
;; When an error occurs, the mouse release event has not been read.
;; We could wait for mouse release to prevent execution of
;; a binding of mouse release, such as cut or paste.
;; In most common cases, next event will be the mouse release.
;; However, it could also be a key stroke before mouse release.
;; Emacs does not allow to test whether mouse is up or down.
;; Not sure it is robust to loop for mouse release after an error
;; occurred, as is done for exploration.
;; So far, we just ignore next event. (Next line also be uncommenting.)
(if event (read-event)))))
(defun caml-types-typed-make-overlay (target-buf pos)
(interactive "p")
(let ((start pos) (end pos) len node left right)
(setq len (length caml-types-annotation-tree))
(while (> len 3)
(setq len (- len 1))
(setq node (aref caml-types-annotation-tree len))
(if (and (equal target-buf (current-buffer))
(setq left (caml-types-get-pos target-buf (elt node 0))
right (caml-types-get-pos target-buf (elt node 1)))
(<= left pos) (> right pos))
(setq start (min start left)
end (max end right))))
(move-overlay caml-types-typed-ovl
(max (point-min) (- start 1))
(min (point-max) (+ end 1)) target-buf)
(cons start end)))
(defun caml-types-version ()
"Internal version number of caml-types.el."
(interactive)
(message "4"))
(provide 'caml-types)
(define-package "caml" "20230129.1145" "Caml mode for GNU Emacs"
'((emacs "24.3"))
:commit "959a5a27bfdbaa43a9ff99be136d62e0798f5e01" :authors
'(("Jacques Garrigue" . "garrigue@kurims.kyoto-u.ac.jp")
("Ian T Zimmerman" . "itz@rahul.net")
("Damien Doligez" . "damien.doligez@inria.fr"))
:maintainers
'(("Christophe Troestler" . "Christophe.Troestler@umons.ac.be"))
:maintainer
'("Christophe Troestler" . "Christophe.Troestler@umons.ac.be")
:keywords
'("ocaml")
:url "https://github.com/ocaml/caml-mode")
;; Local Variables:
;; no-byte-compile: t
;; End:
;;; caml-help.el --- Contextual completion and help to caml-mode -*- lexical-binding: t; -*-
;**************************************************************************
;* *
;* OCaml *
;* *
;* Didier Remy, projet Cristal, INRIA Rocquencourt *
;* *
;* Copyright 2001 Institut National de Recherche en Informatique et *
;* en Automatique. *
;* *
;* All rights reserved. This file is distributed under the terms of *
;* the GNU General Public License. *
;* *
;**************************************************************************
;; Author: Didier Remy, November 2001.
;;; Commentary:
;; This provides two functions: completion and help.
;; Look for caml-complete and caml-help.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;
;; This is a preliminary version.
;;
;; Possible improvements?
;; - dump some databases: Info, Lib, ...
;; - accept a search path for local libraries instead of current dir
;; (then distinguish between different modules lying in different
;; directories)
;; - improve the construction for info files.
;;
;; Abstract over
;; - the viewing method and the database, so that the documentation for
;; an identifier could be
;; * searched in info / html / man / mli's sources
;; * viewed in Emacs or using an external previewer.
;;
;; Take all identifiers (labels, Constructors, exceptions, etc.)
;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; Code:
(require 'info)
(require 'view)
;; Loading or building databases.
;;
;; variables to be customized
(defgroup caml-help nil
"Customizations for `caml-help'."
:group 'languages)
(require 'cl-lib)
(defvar ocaml-lib-path 'lazy
"Path list for ocaml lib sources (mli files).
`lazy' means ask ocaml to find it for you at first use.")
(defun ocaml-lib-path ()
"Compute if necessary and return the path for ocaml libs."
(if (listp ocaml-lib-path) nil
(setq ocaml-lib-path
(split-string
(shell-command-to-string
(or
(and (boundp 'inferior-caml-program)
(string-match "\\([^ ]*/ocaml\\)\\( \\|$\\)"
inferior-caml-program)
(let ((file
(concat (match-string 1 inferior-caml-program)
"c")))
(and (file-executable-p file)
(concat file " -where"))))
"ocamlc -where")))))
ocaml-lib-path)
;; General purpose auxiliary functions
(defun ocaml-capitalize (s)
(concat (capitalize (substring s 0 1)) (substring s 1)))
(defun ocaml-uncapitalize (s)
(if (> (length s) 0)
(concat (downcase (substring s 0 1)) (substring s 1))
s))
(defun ocaml-find-files (path filter &optional depth split)
(let* ((path-string
(if (stringp path)
(if (file-directory-p path) path nil)
(mapconcat (lambda (d) (if (file-directory-p d) d))
path " ")))
(command
(and path-string
(concat "find " path-string
" '(' " filter " ')' "
(if depth (concat " -maxdepth " (int-to-string depth)))
(if split nil " -printf '%\p '")
)))
(files
(and command (shell-command-to-string command))))
(if (and split (stringp files)) (split-string files "\n") files)
))
;; Specialized auxiliary functions
;; Global table of modules contents of modules loaded lazily.
(defvar ocaml-module-alist 'lazy
"A-list of modules with how and where to find help information.
`delay' means non computed yet.")
(defun ocaml-add-mli-modules (modules tag &optional path)
(let ((files
(ocaml-find-files (or path (ocaml-lib-path))
"-type f -name '*.mli'" 1 t)))
(while (consp files)
(if (string-match "\\([^/]*\\).mli" (car files))
(let* ((module (ocaml-capitalize (match-string 1 (car files))))
(dir (file-name-directory (car files)))
(dirp (member dir (ocaml-lib-path))))
(if (and (consp dirp) (string-equal dir (car dirp)))
(setq dir (car dirp)))
(if (assoc module modules) nil
(setq modules
(cons (cons module (cons (cons tag dir) 'lazy)) modules))
)))
(setq files (cdr files)))
modules))
(defun ocaml-add-path (dir &optional _path)
"Extend `ocaml-module-alist' with modules of DIR relative to PATH."
(interactive "D")
(let* ((old (ocaml-lib-path))
(new
(if (file-name-absolute-p dir) dir
(concat
(or (cl-find-if (lambda (p) (file-directory-p (concat p "/" dir)))
(cons default-directory old))
(error "Directory not found"))
"/" dir))))
(setq ocaml-lib-path (cons (car old) (cons new (cdr old))))
(setq ocaml-module-alist
(ocaml-add-mli-modules (ocaml-module-alist) 'lib new))))
(defun ocaml-module-alist ()
"Call by need value of variable `ocaml-module-alist'."
(if (listp ocaml-module-alist)
nil
;; build list of mli files
(setq ocaml-module-alist (ocaml-add-mli-modules nil 'lib))
;; dumping information ? TODO
)
ocaml-module-alist)
(defun ocaml-get-or-make-module (module &optional _tag)
(let ((info (assoc module (ocaml-module-alist))))
(if info nil
(setq info (cons module (cons (cons 'local default-directory) 'lazy)))
(push info ocaml-module-alist)
)
info))
;; Symbols of module are lazily computed
(defun ocaml-module-filename (module)
(let ((module (ocaml-uncapitalize module)) (name))
(if (file-exists-p (setq name (concat module ".mli"))) nil
(let ((tmp (ocaml-lib-path)))
(while (consp tmp)
(setq name (concat (car tmp) "/" module ".mli"))
(if (file-exists-p name) (setq tmp nil)
(setq name nil)))))
name))
(defun ocaml-module-symbols (module-info)
(let* ((module (car module-info))
(tail (and module-info (cdr module-info)))
(tag (caar tail))
(dir (cdar tail))
(file)
(alist))
(if (listp (cdr tail))
(cdr tail)
(if (equal tag 'info)
(setq dir (car ocaml-lib-path)) ; XXX to be fixed
)
(setq file (concat dir "/" (ocaml-uncapitalize module) ".mli"))
(message file)
(save-window-excursion
(set-buffer (get-buffer-create "*caml-help*"))
(if (and file (file-exists-p file))
(progn
(message "Scanning module %s" file)
(insert-file-contents file))
(message "Module %s not found" module))
(while (re-search-forward
(concat "\\([ \t]*val\\|let\\|exception\\|external\\| [|]\\) \\([a-zA-Z_0-9'][a-zA-Z_0-9']*\\)"
"\\|^ *[{]* \\([a-z_][A-Za-z_0-9]*\\) : [^;\n][^;\n]*;")
(point-max) 'move)
(pop-to-buffer (current-buffer))
(setq alist (cons (or (match-string 2) (match-string 3)) alist)))
(erase-buffer)
)
(setcdr tail alist)
alist)
))
;; Local list of visible modules.
(defvar ocaml-visible-modules 'lazy
"A-list of open modules, local to every file.")
(make-variable-buffer-local 'ocaml-visible-modules)
(defun ocaml-visible-modules ()
(if (listp ocaml-visible-modules) nil
(progn
(setq ocaml-visible-modules
(list (ocaml-get-or-make-module "Pervasives")))
(save-excursion
(goto-char (point-min))
(while (re-search-forward "^ *open *\\([A-Z][a-zA-Z'_0-9]*\\)"
(point-max) t)
(let ((module (match-string 1)))
(if (assoc module ocaml-visible-modules) nil
(setq ocaml-visible-modules
(cons (ocaml-get-or-make-module module)
ocaml-visible-modules)))))
)))
ocaml-visible-modules)
(defun ocaml-open-module (arg)
"*Make module of name ARG visible when ARG is a string.
When call interactively, make completion over known modules."
(interactive "P")
(if (not (stringp arg))
(let ((modules (ocaml-module-alist)))
(setq arg
(completing-read "Open module: " modules))))
(if (and (stringp arg) (not (equal arg "")))
(progn
(if (assoc arg (ocaml-visible-modules))
(ocaml-close-module arg))
(setq ocaml-visible-modules
(cons (ocaml-get-or-make-module arg) (ocaml-visible-modules)))
))
(message "%S" (mapcar #'car (ocaml-visible-modules))))
(defun ocaml-close-module (arg)
"*Close module of name ARG when ARG is a string.
When call interactively, make completion over visible modules.
Otherwise if ARG is true, close all modules and reset to default."
(interactive "P")
(if (= (prefix-numeric-value arg) 4)
(setq ocaml-visible-modules 'lazy)
(let* ((modules (ocaml-visible-modules)))
(if (null modules) (error "No visible module to close"))
(unless (stringp arg)
(setq arg
(completing-read
(concat "Close module [" (caar modules) "] : ")
modules))
(if (equal arg "") (setq arg (caar modules))))
(setq ocaml-visible-modules
(cl-remove-if (lambda (m) (equal (car m) arg))
ocaml-visible-modules))
))
(message "%S" (mapcar #'car (ocaml-visible-modules))))
;; Look for identifiers around point
(defun ocaml-qualified-identifier (&optional show)
"Search for a qualified identifier (Path. entry) around point.
Entry may be nil.
Currently, the path may only be nil or a single Module.
For paths is of the form Module.Path', it returns Module
and always nil for entry.
If defined Module and Entry are represented by a region in the buffer,
and are nil otherwise.
For debugging purposes, it returns the string Module.entry if called
with an optional non-nil argument."
(save-excursion
(let ((module) (entry))
(if (looking-at "[ \n]") (skip-chars-backward " "))
(if (re-search-backward
"\\([^A-Za-z0-9_.']\\|\\`\\)\\([A-Za-z0-9_']*[.]\\)*[A-Za-z0-9_']*\\="
(- (point) 100) t)
(progn
(or (looking-at "\\`[A-Za-z)-9_.]") (forward-char 1))
(if (looking-at "\\<\\([A-Za-z_][A-Za-z0-9_']*\\)[.]")
(progn
(setq module (cons (match-beginning 1) (match-end 1)))
(goto-char (match-end 0))))
(if (looking-at "\\<\\([A-Za-z_][A-Za-z0-9_']*\\)\\>")
(setq entry (cons (match-beginning 1) (match-end 1))))))
(if show
(concat
(and module (buffer-substring (car module) (cdr module)))
"."
(and entry (buffer-substring (car entry) (cdr entry))))
(cons module entry))
)))
;; completion around point
(defun ocaml-completion (pattern module)
(let ((list
(or
(and module
(list
(or (assoc module (ocaml-module-alist))
(error "Unknown module %s" module))))
(ocaml-visible-modules))))
(message "Completion from %s" (mapconcat #'car list " "))
(if (null pattern)
(apply #'append (mapcar #'ocaml-module-symbols list))
(let ((pat (concat "^" (regexp-quote pattern))) (res))
(mapc
(lambda (l)
(mapc (lambda (x)
(if (string-match pat (car l))
(if (member x res) nil (setq res (cons x res)))))
(ocaml-module-symbols l)))
list)
res)
)))
(defun caml-complete (&optional _arg)
"Does completion for OCaml identifiers qualified.
It attemps to recognize a qualified identifier Module . entry
around point using function \\[ocaml-qualified-identifier].
If Module is defined, it does completion for identifier in Module.
If Module is undefined, it does completion in visible modules.
Then, if completion fails, it does completion among all modules
where identifier is defined."
(interactive "")
(let* ((module-entry (ocaml-qualified-identifier))
(module) ;; (entry)
(beg) (end) (pattern))
(if (car module-entry)
(progn
(setq module
(buffer-substring (caar module-entry) (cdar module-entry)))
(or (assoc module (ocaml-module-alist))
(and (setq module
(completing-read "Module: " (ocaml-module-alist)
nil nil module))
(save-excursion
(goto-char (caar module-entry))
(delete-region (caar module-entry) (cdar module-entry))
(insert module) t)
(setq module-entry (ocaml-qualified-identifier))
(car module-entry)
(progn ;; (setq entry (cdr module-entry))
t))
(error "Unknown module %s" module))))
(if (consp (cdr module-entry))
(progn
(setq beg (cadr module-entry))
(setq end (cddr module-entry)))
(if (and module
(save-excursion
(goto-char (cdar module-entry))
(looking-at " *[.]")))
(progn
(setq beg (match-end 0))
(setq end beg))))
(if (not (and beg end))
(error "Did not find anything to complete around point")
(setq pattern (buffer-substring beg end))
(let* ((all-completions (ocaml-completion pattern module))
(completion
(try-completion pattern all-completions)))
(cond ((eq completion t))
((null completion)
(let*
((modules (ocaml-find-module pattern))
(visible (cl-intersection modules (ocaml-visible-modules)))
(hist)
(module
(cond
((null modules)
nil)
((equal (length modules) 1)
(caar modules))
((equal (length visible) 1)
(caar visible))
(t
(setq hist (mapcar #'car modules))
(completing-read "Module: " modules nil t
"" (cons hist 0)))
)))
(if (null module)
(error "Can't find completion for \"%s\"" pattern)
(message "Completion found in module %s" module)
(if (and (consp module-entry) (consp (cdr module-entry)))
(delete-region (caar module-entry) end)
(delete-region beg end))
(insert module "." pattern))))
((not (string-equal pattern completion))
(delete-region beg end)
(goto-char beg)
(insert completion))
(t
(with-output-to-temp-buffer "*Completions*"
(display-completion-list all-completions))
))
))))
;; Info files (only in ocamldoc style)
(defvar ocaml-info-prefix "ocaml-lib"
"Prefix of ocaml info files describing library modules.
Suffix .info will be added to info files.
Additional suffix .gz may be added if info files are compressed.")
;;
(defun ocaml-hevea-info-add-entries (entries dir name)
(let*
((filter
(concat "-type f -regex '.*/" name
"\\(.info\\|\\)\\(-[0-9]*\\|\\)\\([.]gz\\|\\)'"
))
(section-regexp
"\\* \\(Section [1-9][0-9--]*\\)::[ \t][ \t]*Module *\\([A-Z][A-Za-z_0-9]*\\)")
(files (ocaml-find-files dir filter))
(command))
;; scanning info files
(if (or (null files)
(not (stringp files))
(string-match files "^ *$"))
(message "No info file found: %s." (mapconcat #'identity files " "))
(message "Scanning info files %s." files)
(save-window-excursion
(set-buffer (get-buffer-create "*caml-help*"))
(setq command
(concat "zcat -f " files
" | grep -e '" section-regexp "'"))
(message "Scanning files with: %s" command)
(or (shell-command command (current-buffer))
(error "Error while scanning"))
(goto-char (point-min))
(while (re-search-forward section-regexp (point-max) t)
(let* ((module (match-string 2))
(section (match-string 1)))
;; (message "%s %s" module section)
(if (assoc module entries) nil
(setq entries
(cons (cons module (concat "(" name ")" section))
entries))
)))
(let ((buf (get-buffer "*caml-help*")))
(if buf (kill-buffer buf)))))
entries))
(defun ocaml-hevea-info ()
"The default way to create an info data base from the value
of \\[Info-default-directory-list] and the base name \\[ocaml-info-name]
of files to look for.
This uses info files produced by HeVeA."
(let ((collect) (seen))
(mapc (lambda (d)
(if (member d seen) nil
(setq collect
(ocaml-hevea-info-add-entries
collect d ocaml-info-prefix))
(setq seen (cons d seen))))
Info-directory-list)
collect))
(defun ocaml-ocamldoc-info-add-entries (entries dir name)
(let*
((module-regexp "^Node: \\([A-Z][A-Za-z_0-9]*\\)[^ ]")
(command
(concat
"find " dir " -type f -regex '.*/" name
"\\(.info\\|\\)\\([.]gz\\|\\)' -print0"
" | xargs -0 zcat -f | grep '" module-regexp "'")))
(message "Scanning info files in %s" dir)
(save-window-excursion
(set-buffer (get-buffer-create "*caml-help*"))
(or (shell-command command (current-buffer))
(error "Could not run:%s" command))
(goto-char (point-min))
(while (re-search-forward module-regexp (point-max) t)
(if (equal (char-after (match-end 1)) 127)
(let* ((module (match-string 1)))
(if (assoc module entries) nil
(setq entries
(cons (cons module (concat "(" name ")" module))
entries))
))))
; (kill-buffer (current-buffer))
)
entries))
(defun ocaml-ocamldoc-info ()
"The default way to create an info data base from the value
of \\[Info-default-directory-list] and the base name \\[ocaml-info-name]
of files to look for.
This uses info files produced by ocamldoc."
(require 'info)
(let ((collect) (seen))
(mapc (lambda (d)
(if (member d seen) nil
(setq collect
(ocaml-ocamldoc-info-add-entries collect d
ocaml-info-prefix))
(setq seen (cons d seen))))
Info-directory-list)
collect))
;; Continuing
(defvar ocaml-info-alist 'ocaml-ocamldoc-info
"A-list binding module names to info entries:
nil means do not use info.
A function to build the list lazily (at the first call). The result of
the function call will be assign permanently to this variable for future
uses. We provide two default functions `ocaml-hevea-info'
\(info produced by HeVeA is the default) and `ocaml-ocamldoc-info'
\(info produced by ocamldoc).
Otherwise, this value should be an alist binding module names to info
entries of the form to \"(entry)section\" be taken by the \\[info]
command. An entry may be an info module or a complete file name."
)
(defun ocaml-info-alist ()
"Call by need value of variable `ocaml-info-alist'."
(cond
((listp ocaml-info-alist))
((functionp ocaml-info-alist)
(setq ocaml-info-alist (apply ocaml-info-alist nil)))
(t
(error "wrong type for ocaml-info-alist")))
ocaml-info-alist)
;; help around point
(defun ocaml-find-module (symbol &optional module-list)
(let ((list (or module-list (ocaml-module-alist)))
(collect))
(while (consp list)
(if (member symbol (ocaml-module-symbols (car list)))
(setq collect (cons (car list) collect)))
(setq list (cdr list)))
(nreverse collect)
))
(defun ocaml-buffer-substring (region)
(and region (buffer-substring-no-properties (car region) (cdr region))))
;; Help function.
(defvar view-return-to-alist)
(defvar view-exit-action)
(defun ocaml-goto-help (&optional module entry same-window)
"Search info manual for MODULE and ENTRY in MODULE.
If unspecified, MODULE and ENTRY are inferred from the position in the
current buffer using \\[ocaml-qualified-identifier]."
(interactive)
(let ((window (selected-window))
(info-section (assoc module (ocaml-info-alist))))
(if info-section
(info-other-window (cdr info-section))
(ocaml-visible-modules)
(let* ((module-info
(or (assoc module (ocaml-module-alist))
(and (file-exists-p
(concat (ocaml-uncapitalize module) ".mli"))
(ocaml-get-or-make-module module))))
(location (cdr (cadr module-info))))
(cond
(location
(let ((file (concat location (ocaml-uncapitalize module) ".mli")))
(if (window-live-p same-window)
(progn (select-window same-window)
(view-mode-exit nil view-exit-action))
;; (view-buffer (find-file-noselect file) 'view))
)
(view-file-other-window file)
(bury-buffer (current-buffer))))
(info-section (error "Aborted"))
(t (error "No help for module %s" module))))
)
(if (stringp entry)
(let ((here (point))
(regex (regexp-quote entry))
(case-fold-search nil))
(goto-char (point-min))
(if (or (re-search-forward
(concat "\\(val\\|exception\\|type\\|external\\|[|{;]\\) +"
regex)
;; (concat "\\(val\\|exception\\|external\\) +\\("
;; regex "\\|( *" regex " *)\\)")
(point-max) t)
(re-search-forward
(concat "type [^{]*{[^}]*" regex " :")
;; (concat "\\(type\\|[|{;]\\) +" regex)
(point-max) t)
(progn
(if (window-live-p window) (select-window window))
(error "Entry %s not found in module %s"
entry module))
;; (search-forward entry (point-max) t)
)
(ocaml-help-show -1)
(progn
(message "Help for entry %s not found in module %s"
entry module)
(goto-char here)))
))
(ocaml-link-activate (cdr info-section))
(if (window-live-p window) (select-window window))
))
(defface ocaml-help-face
'((t :background "#88FF44"))
"Face to highlight expressions and types.")
(defvar ocaml-help-ovl
(let ((ovl (make-overlay 1 1)))
(overlay-put ovl 'face 'ocaml-help-face)
ovl))
(defun caml-help (arg)
"Find documentation for OCaml qualified identifiers.
It attempts to recognize a qualified identifier of the form
``Module . entry'' around point using function `ocaml-qualified-identifier'.
If Module is undetermined it is temptatively guessed from the identifier name
and according to visible modules. If this is still unsuccessful, the user is
then prompted for a Module name.
The documentation for Module is first searched in the info manual, if available,
then in the ``module.mli'' source file. The entry is then searched in the
documentation.
Visible modules are computed only once, at the first call.
Modules can be made visible explicitly with `ocaml-open-module' and
hidden with `ocaml-close-module'.
Prefix arg 0 forces recompilation of visible modules (and their content)
from the file content.
Prefix arg 4 prompts for Module and identifier instead of guessing values
from the position of point in the current buffer."
(interactive "p")
(delete-overlay ocaml-help-ovl)
(let ((module) (entry) (module-entry))
(cond
((= arg 4)
(or (and
(setq module
(completing-read "Module: " (ocaml-module-alist)
nil t "" (cons 'hist 0)))
(not (string-equal module "")))
(error "Quit"))
(let ((symbols
(mapcar #'list
(ocaml-module-symbols
(assoc module (ocaml-module-alist))))))
(setq entry
(completing-read (format "Value: %s." module) symbols nil t)))
(if (string-equal entry "") (setq entry nil))
)
(t
(if (= arg 0) (setq ocaml-visible-modules 'lazy))
(setq module-entry (ocaml-qualified-identifier))
(setq entry (ocaml-buffer-substring (cdr module-entry)))
(setq module
(or (ocaml-buffer-substring (car module-entry))
(let ((modules
(or (ocaml-find-module entry (ocaml-visible-modules))
(ocaml-find-module entry)))
(hist) (default))
(cond
((null modules)
(error "No module found for entry %s" entry))
((equal (length modules) 1)
(caar modules))
(t
(setq hist (mapcar #'car modules))
(setq default (car hist))
(setq module
(completing-read
(concat "Module: "
(and default (concat "[" default "] ")))
modules nil t "" (cons 'hist 0)))
(if (string-equal module "") default module))
))))
))
(message "Help for %s%s%s" module (if entry "." "") (or entry ""))
(ocaml-goto-help module entry)
))
;; auto-links
(defconst ocaml-link-regexp
"\\(type\\|and\\) \\('[a-z] +\\|(\\('[a-z], *\\)*'[a-z])\\|\\) *\\([a-zA-Z0-9_]*\\)\\( *$\\| =\\)")
(defconst ocaml-longident-regexp
"\\([A-Z][a-zA-Z_0]*\\)[.]\\([a-zA-Z][A-Za-z0-9_]*\\)")
(defvar ocaml-links nil
"Local links in the current of last info node or interface file.
The car of the list is a key that identifies the module to prevent
recompilation when next help command is relative to the same module.
The cdr is a list of elements, each of which is a string and a pair of
buffer positions."
)
(make-variable-buffer-local 'ocaml-links)
(defun ocaml-info-links (section)
(cdr
(if (and ocaml-links section (equal (car ocaml-links) section))
ocaml-links
(save-excursion
(goto-char (point-min))
(let ((regexp (concat (if (equal major-mode 'Info-mode) "^ - " "^")
ocaml-link-regexp))
(all))
(while (re-search-forward regexp (point-max) t)
(setq all
(cons (cons (match-string 4)
(cons (match-beginning 4)
(match-end 4)))
all)))
(setq ocaml-links (cons section all))
)))))
(defvar ocaml-link-map
(let ((map (make-sparse-keymap)))
(define-key map [mouse-2] 'ocaml-link-goto)
map))
(defun ocaml-help-show (arg)
(let ((right (point))
(left (progn (forward-word arg) (point))))
(goto-char right)
(move-overlay ocaml-help-ovl left right (current-buffer))
(recenter 1)
))
(defun ocaml-link-goto (click)
"Follow link at point."
(interactive "e")
(let* ((pos-click (event-start click))
(pos (posn-point pos-click))
(win (posn-window pos-click))
(buf (window-buffer win))
(window (selected-window))
(link))
(setq link
(with-current-buffer buf
(buffer-substring
(previous-single-property-change (+ pos 1) 'local-map
buf (- pos 100))
(next-single-property-change pos 'local-map
buf (+ pos 100)))))
(if (string-match (concat "^" ocaml-longident-regexp "$") link)
(ocaml-goto-help (match-string 1 link) (match-string 2 link) win)
(if (not (equal (window-buffer window) buf))
(switch-to-buffer-other-window buf))
(if (setq link (assoc link (cdr ocaml-links)))
(progn
(goto-char (cadr link))
(ocaml-help-show 1)))
(if (window-live-p window) (select-window window))
)))
(defface ocaml-link-face
'((((class color)) :foreground "Purple"))
"Face to highlight hyperlinks.")
(defun ocaml-link-activate (section)
(let ((links (ocaml-info-links section)))
(if links
(let ((regexp (concat "[^A-Za-z0-9'_]\\("
ocaml-longident-regexp "\\|"
(mapconcat #'car links "\\|")
"\\)[^A-Za-z0-9'_]"))
(case-fold-search nil))
(save-excursion
(goto-char (point-min))
(let ((buffer-read-only nil)
;; use of dynamic scoping, need not be restored!
(modified-p (buffer-modified-p)))
(unwind-protect
(save-excursion
(goto-char (point-min))
(while (re-search-forward regexp (point-max) t)
(put-text-property (match-beginning 1) (match-end 1)
'mouse-face 'highlight)
(put-text-property (match-beginning 1) (match-end 1)
'local-map ocaml-link-map)
(if (x-display-color-p)
(put-text-property (match-beginning 1) (match-end 1)
'face 'ocaml-link-face)))
)
;; need to restore flag if buffer was unmodified.
(unless modified-p (set-buffer-modified-p nil))
))
)))))
;; bindings ---now in caml.el
; (and
; (boundp 'caml-mode-map)
; (keymapp caml-mode-map)
; (progn
; (define-key caml-mode-map [?\C-c?i] 'ocaml-add-path)
; (define-key caml-mode-map [?\C-c?]] 'ocaml-close-module)
; (define-key caml-mode-map [?\C-c?[] 'ocaml-open-module)
; (define-key caml-mode-map [?\C-c?\C-h] 'caml-help)
; (define-key caml-mode-map [?\C-c?\t] 'caml-complete)
; (let ((map (lookup-key caml-mode-map [menu-bar caml])))
; (and
; (keymapp map)
; (progn
; (define-key map [separator-help] '("---"))
; (define-key map [open] '("Open add path" . ocaml-add-path ))
; (define-key map [close]
; '("Close module for help" . ocaml-close-module))
; (define-key map [open] '("Open module for help" . ocaml-open-module))
; (define-key map [help] '("Help for identifier" . caml-help))
; (define-key map [complete] '("Complete identifier" . caml-complete))
; )
; ))))
(provide 'caml-help)
;;; caml-help.el ends here
;****************************************** -*- lexical-binding: t; -*- ***
;* *
;* OCaml *
;* *
;* Jacques Garrigue, Ian T Zimmerman, Damien Doligez *
;* *
;* Copyright 1997 Institut National de Recherche en Informatique et *
;* en Automatique. *
;* *
;* All rights reserved. This file is distributed under the terms of *
;* the GNU General Public License. *
;* *
;**************************************************************************
;; caml-font: font-lock support for OCaml files
;; now with perfect parsing of comments and strings
(require 'font-lock)
(defvar caml-font-stop-face
(progn
(make-face 'caml-font-stop-face)
(set-face-foreground 'caml-font-stop-face "White")
(set-face-background 'caml-font-stop-face "Red")
'caml-font-stop-face))
(defvar caml-font-doccomment-face
(progn
(make-face 'caml-font-doccomment-face)
(set-face-foreground 'caml-font-doccomment-face "Red")
'caml-font-doccomment-face))
(defconst caml-font-lock-keywords
`(
;modules and constructors
("`?\\<[A-Z][A-Za-z0-9_']*\\>" . font-lock-function-name-face)
;definition
(,(regexp-opt '("and" "as" "constraint" "class"
"exception" "external" "fun" "function" "functor"
"in" "inherit" "initializer" "let"
"method" "mutable" "module" "of" "private" "rec"
"type" "val" "virtual")
'words)
. font-lock-type-face)
;blocking
(,(regexp-opt '("begin" "end" "object" "sig" "struct") 'words)
. font-lock-keyword-face)
;linenums
("# *[0-9]+" . font-lock-preprocessor-face)
;infix operators
(,(regexp-opt '("asr" "land" "lor" "lsl" "lsr" "lxor" "mod") 'words)
. font-lock-builtin-face)
;control
(,(concat "[|#&]\\|->\\|"
(regexp-opt '("do" "done" "downto" "else" "for" "if" "ignore"
"lazy" "match" "new" "or" "then" "to" "try"
"when" "while" "with")
'words))
. font-lock-constant-face)
("\\<\\(raise\\|failwith\\|invalid_arg\\)\\>"
. font-lock-comment-face)
;labels (and open)
("\\(\\([~?]\\|\\<\\)[a-z][a-zA-Z0-9_']*:\\)[^:=]"
1 font-lock-variable-name-face)
("\\<\\(assert\\|open\\|include\\)\\>\\|[~?][ (]*[a-z][a-zA-Z0-9_']*"
. font-lock-variable-name-face)))
(defun caml-font-syntactic-face (s)
(let ((in-string (nth 3 s))
(in-comment (nth 4 s))
(start (nth 8 s)))
(cond
(in-string 'font-lock-string-face)
(in-comment
(save-excursion
(goto-char start)
(cond
((looking-at "(\\*\\*/\\*\\*)") 'caml-font-stop-face)
((looking-at "(\\*\\*[^*]") 'caml-font-doccomment-face)
(t 'font-lock-comment-face)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; In order to correctly fontify an OCaml buffer, it is necessary to
; lex the buffer to tell what is a comment and what is a string.
; We do this incrementally in a hook
; (font-lock-extend-after-change-region-function), which is called
; whenever the buffer changes. It sets the syntax-table property
; on each beginning and end of chars, strings, and comments.
; This mode handles correctly all the strange cases in the following
; OCaml code.
;
; let l' _ = ();;
; let _' _ = ();;
; let l' = ();;
; let b2_' = ();;
; let a'a' = ();;
; let f2 _ _ = ();;
; let f3 _ _ _ = ();;
; let f' _ _ _ _ _ = ();;
; let hello = ();;
;
; (* ==== easy stuff ==== *)
;
; (* a comment *)
; (* "a string" in a comment *)
; (* "another string *)" in a comment *)
; (* not a string '"' in a comment *)
; "a string";;
; '"';; (* not a string *)
;
; (* ==== hard stuff ==== *)
;
; l'"' not not a string ";;
; _'"' also not not a string";;
; f2 0l'"';; (* not not not a string *)
; f2 0_'"';; (* also not not not a string *)
; f3 0.0l'"' not not not not a string ";;
; f3 0.0_'"';; (* not not not not not a string *)
; f2 0b01_'"';; (* not not not a string *)
; f3 0b2_'"' not not not not a string ";;
; f3 0b02_'"';; (* not not not not not a string *)
; '\'';; (* a char *)
; '
; ';; (* a char *)
; '^M
; ';; (* also a char [replace ^M with one CR character] *)
; a'a';; (* not a char *)
; type '
; a' t = X;; (* also not a char *)
;
; (* ==== far-out stuff ==== *)
;
; f'"'" "*) print_endline "hello";;(* \"" ;;
; (* f'"'" "*) print_endline "hello";;(* \"" ;; *)
(defconst caml-font-ident-re
(concat "[A-Za-z_\300-\326\330-\366\370-\377]"
"[A-Za-z_\300-\326\330-\366\370-\377'0-9]*")
)
(defconst caml-font-int-re
(concat "\\(0[xX][0-9A-Fa-f][0-9A-Fa-f_]*\\|0[oO][0-7][0-7_]*"
"\\|0[bB][01][01_]*\\)[lLn]?")
)
; decimal integers are folded into the RE for floats to get longest-match
; without using posix-looking-at
(defconst caml-font-decimal-re
"[0-9][0-9_]*\\([lLn]\\|\\.[0-9_]*\\)?\\([eE][+-]?[0-9][0-9_]*\\)?"
)
; match any ident or numeral token
(defconst caml-font-ident-or-num-re
(concat caml-font-ident-re "\\|" caml-font-int-re "\\|" caml-font-decimal-re)
)
; match any char token
(defconst caml-font-char-re
(concat "'\\(\015\012\\|[^\\']\\|"
"\\(\\\\\\([\\'\"ntbr ]\\|[0-9][0-9][0-9]\\|o[0-3][0-7][0-7]"
"\\|x[0-9A-Fa-f][0-9A-Fa-f]\\)\\)\\)'")
)
; match a quote followed by a newline
(defconst caml-font-quote-newline-re
"'\\(\015\012\\|[\012\015]\\)"
)
; match an opening delimiter for a quoted string
(defconst caml-font-quoted-string-start-re
"{\\([a-z]*\\)|"
)
; match any token or sequence of tokens that cannot contain a
; quote, double quote, a start of comment or quoted string, or a newline
; note: this is only to go faster than one character at a time
(defconst caml-font-other-re
"[^A-Za-z_0-9\012\015\300-\326\330-\366\370-\377'\"({]+"
)
; match any sequence of non-special characters in a comment
; note: this is only to go faster than one character at a time
(defconst caml-font-other-comment-re
"[^A-Za-z_\300-\326\330-\366\370-\377{(*\"'\012\015]+"
)
; match any sequence of non-special characters in a string
; note: this is only to go faster than one character at a time
(defconst caml-font-other-string-re
"[^|\\\"\012\015]"
)
; match a newline
(defconst caml-font-newline-re
"\\(\015\012\\|[\012\015]\\)"
)
; Put the 'caml-font-state property with the given state on the
; character before pos. Return nil if it was already there, t if not.
(defun caml-font-put-state (pos state)
(if (equal state (get-text-property (1- pos) 'caml-font-state))
nil
(put-text-property (1- pos) pos 'caml-font-state state)
t)
)
; Same as looking-at, but erase properties 'caml-font-state and
; 'syntax-table from the matched range
(defun caml-font-looking-at (re)
(let ((result (looking-at re)))
(when result
(remove-text-properties (match-beginning 0) (match-end 0)
'(syntax-table nil caml-font-state nil)))
result)
)
; Annotate the buffer starting at point in state (st . depth)
; Set the 'syntax-table property on beginnings and ends of:
; - strings
; - chars
; - comments
; Also set the 'caml-font-state property on each LF character that is
; not preceded by a single quote. The property gives the state of the
; lexer (nil or t) after reading that character.
; Leave the point at a point where the pre-existing 'caml-font-state
; property is consistent with the new parse, or at the end of the buffer.
; depth is the depth of nested comments at this point
; it must be a non-negative integer
; st can be:
; nil -- we are in the base state
; t -- we are within a string
; a string -- we are within a quoted string and st is the closing delimiter
(defun caml-font-annotate (st depth)
(let ((continue t))
(while (and continue (not (eobp)))
(cond
((and (equal st nil) (= depth 0)) ; base state, outside comment
(cond
((caml-font-looking-at caml-font-ident-or-num-re)
(goto-char (match-end 0)))
((caml-font-looking-at caml-font-char-re)
(put-text-property (point) (1+ (point))
'syntax-table (string-to-syntax "|"))
(put-text-property (1- (match-end 0)) (match-end 0)
'syntax-table (string-to-syntax "|"))
(goto-char (match-end 0)))
((caml-font-looking-at caml-font-quote-newline-re)
(goto-char (match-end 0)))
((caml-font-looking-at "\"")
(put-text-property (point) (1+ (point))
'syntax-table (string-to-syntax "|"))
(goto-char (match-end 0))
(setq st t))
((caml-font-looking-at caml-font-quoted-string-start-re)
(put-text-property (point) (1+ (point))
'syntax-table (string-to-syntax "|"))
(goto-char (match-end 0))
(setq st (concat "|" (match-string 1) "}")))
((caml-font-looking-at "(\\*")
(put-text-property (point) (1+ (point))
'syntax-table (string-to-syntax "!"))
(goto-char (match-end 0))
(setq depth 1))
((looking-at caml-font-newline-re)
(goto-char (match-end 0))
(setq continue (caml-font-put-state (match-end 0) '(nil . 0))))
((caml-font-looking-at caml-font-other-re)
(goto-char (match-end 0)))
(t
(remove-text-properties (point) (1+ (point))
'(syntax-table nil caml-font-state nil))
(goto-char (1+ (point))))))
((equal st nil) ; base state inside comment
(cond
((caml-font-looking-at "(\\*")
(goto-char (match-end 0))
(setq depth (1+ depth)))
((caml-font-looking-at "\\*)")
(goto-char (match-end 0))
(setq depth (1- depth))
(when (= depth 0)
(put-text-property (1- (point)) (point)
'syntax-table (string-to-syntax "!"))))
((caml-font-looking-at "\"")
(goto-char (match-end 0))
(setq st t))
((caml-font-looking-at caml-font-char-re)
(goto-char (match-end 0)))
((caml-font-looking-at caml-font-quote-newline-re)
(goto-char (match-end 0)))
((caml-font-looking-at "''")
(goto-char (match-end 0)))
((looking-at caml-font-newline-re)
(goto-char (match-end 0))
(setq continue (caml-font-put-state (match-end 0) (cons nil depth))))
((caml-font-looking-at caml-font-ident-re)
(goto-char (match-end 0)))
((caml-font-looking-at caml-font-other-comment-re)
(goto-char (match-end 0)))
(t
(remove-text-properties (point) (1+ (point))
'(syntax-table nil caml-font-state nil))
(goto-char (1+ (point))))))
((equal st t) ; string state inside or outside a comment
(cond
((caml-font-looking-at "\"")
(when (= depth 0)
(put-text-property (point) (1+ (point))
'syntax-table (string-to-syntax "|")))
(goto-char (1+ (point)))
(setq st nil))
((caml-font-looking-at "\\\\[\"\\]")
(goto-char (match-end 0)))
((looking-at caml-font-newline-re)
(goto-char (match-end 0))
(setq continue (caml-font-put-state (match-end 0) (cons t depth))))
((caml-font-looking-at caml-font-other-string-re)
(goto-char (match-end 0)))
(t
(remove-text-properties (point) (1+ (point))
'(syntax-table nil caml-font-state nil))
(goto-char (1+ (point))))))
((stringp st) ; quoted-string state inside or outside comment
(cond
((caml-font-looking-at st)
(when (= depth 0)
(put-text-property (1- (match-end 0)) (match-end 0)
'syntax-table (string-to-syntax "|")))
(goto-char (match-end 0))
(setq st nil))
((caml-font-looking-at caml-font-other-string-re)
(goto-char (match-end 0)))
(t
(remove-text-properties (point) (1+ (point))
'(syntax-table nil caml-font-state nil))
(goto-char (1+ (point))))))
(t ; should not happen
(remove-text-properties (point) (1+ (point))
'(syntax-table nil caml-font-state nil))))))
)
; This is the hook function for font-lock-extend-after-change-function
; It finds the nearest saved state at the left of the changed text,
; calls caml-font-annotate to set the 'caml-font-state and 'syntax-table
; properties, then returns the range that was parsed by caml-font-annotate.
(defun caml-font-extend-after-change (beg end &optional _old-len)
(save-excursion
(save-match-data
(let ((caml-font-modified (buffer-modified-p))
start-at
end-at
state)
(remove-text-properties beg end '(syntax-table nil caml-font-state nil))
(setq start-at
(or (and (> beg (point-min))
(get-text-property (1- beg) 'caml-font-state)
beg)
(previous-single-property-change beg 'caml-font-state)
(point-min)))
(setq state (or (and (> start-at (point-min))
(get-text-property (1- start-at) 'caml-font-state))
(cons nil 0)))
(goto-char start-at)
(caml-font-annotate (car state) (cdr state))
(setq end-at (point))
(restore-buffer-modified-p caml-font-modified)
(cons start-at end-at))))
)
; We don't use the normal caml-mode syntax table because it contains an
; approximation of strings and comments that interferes with our
; annotations.
(defconst caml-font-syntax-table
(let ((tbl (make-syntax-table)))
(modify-syntax-entry ?' "w" tbl)
(modify-syntax-entry ?_ "w" tbl)
(modify-syntax-entry ?\" "." tbl)
(let ((i 192))
(while (< i 256)
(or (= i 215) (= i 247) (modify-syntax-entry i "w" tbl))
(setq i (1+ i))))
tbl))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; font-lock commands are similar for caml-mode and inferior-caml-mode
(defun caml-font-set-font-lock ()
(setq parse-sexp-lookup-properties t)
(setq font-lock-defaults
(list
'caml-font-lock-keywords ; keywords
nil ; keywords-only
nil ; case-fold
nil ; syntax-alist
nil ; syntax-begin
(cons 'font-lock-syntax-table caml-font-syntax-table)
'(font-lock-extend-after-change-region-function
. caml-font-extend-after-change)
'(font-lock-syntactic-face-function . caml-font-syntactic-face)
))
(caml-font-extend-after-change (point-min) (point-max) 0)
(font-lock-mode 1)
)
(add-hook 'caml-mode-hook 'caml-font-set-font-lock)
(defconst inferior-caml-font-lock-keywords
`(("^[#-]" . font-lock-comment-face)
,@caml-font-lock-keywords))
(defun inferior-caml-set-font-lock ()
(setq parse-sexp-lookup-properties t)
(setq font-lock-defaults
(list
'inferior-caml-font-lock-keywords ; keywords
nil ; keywords-only
nil ; case-fold
nil ; syntax-alist
nil ; syntax-begin
(cons 'font-lock-syntax-table caml-font-syntax-table)
'(font-lock-extend-after-change-region-function
. caml-font-extend-after-change)
'(font-lock-syntactic-face-function . caml-font-syntactic-face)
))
(caml-font-extend-after-change (point-min) (point-max) 0)
(font-lock-mode 1)
)
(add-hook 'inferior-caml-mode-hooks 'inferior-caml-set-font-lock)
(provide 'caml-font)
;****************************************** -*- lexical-binding: t; -*- ***
;* *
;* OCaml *
;* *
;* Jacques Garrigue and Ian T Zimmerman *
;* *
;* Copyright 1997 Institut National de Recherche en Informatique et *
;* en Automatique. *
;* *
;* All rights reserved. This file is distributed under the terms of *
;* the GNU General Public License. *
;* *
;**************************************************************************
;; useful colors
(cond
((x-display-color-p)
(require 'font-lock)
;; extra faces for documentation
(make-face 'Stop)
(set-face-foreground 'Stop "White")
(set-face-background 'Stop "Red")
(make-face 'Doc)
(set-face-foreground 'Doc "Red")
(setq font-lock-stop-face 'Stop)
(setq font-lock-doccomment-face 'Doc)
))
; The same definition is in caml.el:
; we don't know in which order they will be loaded.
(defvar caml-quote-char "'"
"*Quote for character constants. \"'\" for OCaml, \"`\" for Caml-Light.")
(defconst caml-font-lock-keywords
(list
;stop special comments
'("\\(^\\|[^\"]\\)\\((\\*\\*/\\*\\*)\\)"
2 font-lock-stop-face)
;doccomments
'("\\(^\\|[^\"]\\)\\((\\*\\*[^*]*\\([^)*][^*]*\\*+\\)*)\\)"
2 font-lock-doccomment-face)
;comments
'("\\(^\\|[^\"]\\)\\((\\*[^*]*\\*+\\([^)*][^*]*\\*+\\)*)\\)"
2 font-lock-comment-face)
;character literals
(cons (concat caml-quote-char "\\(\\\\\\([ntbr" caml-quote-char "\\]\\|"
"[0-9][0-9][0-9]\\)\\|.\\)" caml-quote-char
"\\|\"[^\"\\]*\\(\\\\\\(.\\|\n\\)[^\"\\]*\\)*\"")
'font-lock-string-face)
;modules and constructors
'("`?\\<[A-Z][A-Za-z0-9_']*\\>" . font-lock-function-name-face)
;definition
(cons (concat
"\\<\\(a\\(nd\\|s\\)\\|c\\(onstraint\\|lass\\)"
"\\|ex\\(ception\\|ternal\\)\\|fun\\(ct\\(ion\\|or\\)\\)?"
"\\|in\\(herit\\|itializer\\)?\\|let"
"\\|m\\(ethod\\|utable\\|odule\\)"
"\\|of\\|p\\(arser\\|rivate\\)\\|rec\\|type"
"\\|v\\(al\\|irtual\\)\\)\\>")
'font-lock-type-face)
;blocking
'("\\<\\(begin\\|end\\|object\\|s\\(ig\\|truct\\)\\)\\>"
. font-lock-keyword-face)
;control
(cons (concat
"\\<\\(do\\(ne\\|wnto\\)?\\|else\\|for\\|i\\(f\\|gnore\\)"
"\\|lazy\\|match\\|new\\|or\\|t\\(hen\\|o\\|ry\\)"
"\\|w\\(h\\(en\\|ile\\)\\|ith\\)\\)\\>"
"\\||\\|->\\|&\\|#")
'font-lock-reference-face)
'("\\<raise\\>" . font-lock-comment-face)
;labels (and open)
'("\\(\\([~?]\\|\\<\\)[a-z][a-zA-Z0-9_']*:\\)[^:=]" 1
font-lock-variable-name-face)
'("\\<\\(assert\\|open\\|include\\)\\>\\|[~?][ (]*[a-z][a-zA-Z0-9_']*"
. font-lock-variable-name-face)))
(defconst inferior-caml-font-lock-keywords
(append
(list
;inferior
'("^[#-]" . font-lock-comment-face))
caml-font-lock-keywords))
;; font-lock commands are similar for caml-mode and inferior-caml-mode
(defun caml-mode-font-hook ()
(cond
((fboundp 'global-font-lock-mode)
(make-local-variable 'font-lock-defaults)
(setq font-lock-defaults
'(caml-font-lock-keywords nil nil ((?' . "w") (?_ . "w")))))
(t
(setq font-lock-keywords caml-font-lock-keywords)))
(make-local-variable 'font-lock-keywords-only)
(setq font-lock-keywords-only t)
(font-lock-mode 1))
(add-hook 'caml-mode-hook 'caml-mode-font-hook)
(defun inferior-caml-mode-font-hook ()
(cond
((fboundp 'global-font-lock-mode)
(make-local-variable 'font-lock-defaults)
(setq font-lock-defaults
'(inferior-caml-font-lock-keywords
nil nil ((?' . "w") (?_ . "w")))))
(t
(setq font-lock-keywords inferior-caml-font-lock-keywords)))
(make-local-variable 'font-lock-keywords-only)
(setq font-lock-keywords-only t)
(font-lock-mode 1))
(add-hook 'inferior-caml-mode-hooks 'inferior-caml-mode-font-hook)
(provide 'caml-font)
;;; caml-autoloads.el --- automatically extracted autoloads -*- lexical-binding: t -*-
;;
;;; Code:
(add-to-list 'load-path (directory-file-name
(or (file-name-directory #$) (car load-path))))
;;;### (autoloads nil "caml" "caml.el" (0 0 0 0))
;;; Generated autoloads from caml.el
(register-definition-prefixes "caml" '("caml-"))
;;;***
;;;### (autoloads nil "caml-font" "caml-font.el" (0 0 0 0))
;;; Generated autoloads from caml-font.el
(register-definition-prefixes "caml-font" '("caml-font-" "inferior-caml-"))
;;;***
;;;### (autoloads nil "caml-font-old" "caml-font-old.el" (0 0 0 0))
;;; Generated autoloads from caml-font-old.el
(register-definition-prefixes "caml-font-old" '("caml-" "inferior-caml-"))
;;;***
;;;### (autoloads nil "caml-help" "caml-help.el" (0 0 0 0))
;;; Generated autoloads from caml-help.el
(register-definition-prefixes "caml-help" '("caml-" "ocaml-"))
;;;***
;;;### (autoloads nil "caml-types" "caml-types.el" (0 0 0 0))
;;; Generated autoloads from caml-types.el
(register-definition-prefixes "caml-types" '("caml-"))
;;;***
;;;### (autoloads nil "camldebug" "camldebug.el" (0 0 0 0))
;;; Generated autoloads from camldebug.el
(defvar camldebug-command-name "ocamldebug" "\
*Pathname for executing camldebug.")
(autoload 'camldebug "camldebug" "\
Run camldebug on program FILE in buffer *camldebug-FILE*.
The directory containing FILE becomes the initial working directory
and source-file directory for camldebug. If you wish to change this, use
the camldebug commands `cd DIR' and `directory'.
\(fn PATH)" t nil)
(register-definition-prefixes "camldebug" '("camldebug-" "current-camldebug-buffer" "def-camldebug"))
;;;***
;;;### (autoloads nil "inf-caml" "inf-caml.el" (0 0 0 0))
;;; Generated autoloads from inf-caml.el
(register-definition-prefixes "inf-caml" '("caml-" "inferior-caml-" "run-caml"))
;;;***
;;;### (autoloads nil nil ("caml-pkg.el") (0 0 0 0))
;;;***
;; Local Variables:
;; version-control: never
;; no-byte-compile: t
;; no-update-autoloads: t
;; coding: utf-8
;; End:
;;; caml-autoloads.el ends here
mastodon.plstore