Commit 544a170ead2c
tarball31 files changed+1255-134
@@ -0,0 +1,58 @@
1
+# macltok request, custom emote and sticker packs (MSC2545)2
+3
+## Problem4
+5
+Malice is adding an emoji picker for reactions that should also offer the user's custom emotes and stickers. Those live in MSC2545 image packs, spread over three places.6
+7
+1. `im.ponies.user_emotes` global account data, the user's personal pack8
+2. `im.ponies.room_emotes` room state, one pack per state key, in the current room9
+3. `im.ponies.emote_rooms` global account data, which names other rooms (and state keys) whose packs the user enabled everywhere10
+11
+Merging those, resolving pack vs image `usage`, and following `emote_rooms` into rooms the client may not have state for is protocol work every consumer would repeat, so it belongs in macltok. Malice can't do it today anyway. `global_account_data` covers 1 and 3, but there is no room state getter for 2.12
+13
+ruma-events 0.34 already has the types behind `unstable-msc2545` (`image_pack.rs`, `RoomImagePackEventContent`, `AccountImagePackEventContent`, `ImagePackRoomsEventContent`, `PackUsage`), including the stable `m.image_pack` aliases.14
+15
+## Request16
+17
+A normalised query for the packs usable in a given room.18
+19
+```rust20
+impl ClientHandle {21
+ /// user pack, then the room's own packs, then emote_rooms packs, deduplicated22
+ pub async fn image_packs(&self, room_id: Option<OwnedRoomId>) -> Result<Vec<ImagePack>, Error>;23
+}24
+25
+pub struct ImagePack {26
+ pub id: String,27
+ pub display_name: String,28
+ pub avatar_url: Option<OwnedMxcUri>,29
+ pub source: ImagePackSource,30
+ pub images: Vec<PackImage>,31
+}32
+33
+pub enum ImagePackSource {34
+ User,35
+ Room { room_id: OwnedRoomId, state_key: String },36
+}37
+38
+pub struct PackImage {39
+ pub shortcode: String,40
+ pub url: OwnedMxcUri,41
+ pub body: Option<String>,42
+ /// image usage if set, else the pack's, else both43
+ pub usage: Vec<PackUsage>,44
+}45
+```46
+47
+`id` only needs to be stable for a pack across calls, e.g. `user` or `room:<room_id>:<state_key>`. Serialised field names should match the rest of macltok's normalised types. Malice's web client already has the matching TypeScript shape in `web/src/lib/types.ts` (`ImagePack`, `PackImage`).48
+49
+It would also help to get a change event when any of the three sources updates, so the picker doesn't go stale, either a dedicated event or folding it into the existing room/account mirror.50
+51
+## Related, reactions with custom emotes (MSC4027)52
+53
+A custom emote reaction uses the image's mxc URI as the `m.annotation` key, with the shortcode in `com.beeper.reaction.shortcode` so clients without the pack can show a label.54
+55
+- `toggle_reaction` only takes a key. An optional shortcode would let malice send the label.56
+- `normalise::timeline::reactions` drops the shortcode on receipt. `Reaction` could carry `shortcode: Option<String>`, taken from any sender's annotation for that key.57
+58
+Malice relaxed its own 32-char key limit for `mxc://` keys, so sending already works without the shortcode.@@ -1,3 +1,4 @@
1
+use macltok::ruma::{events::GlobalAccountDataEventType, serde::Raw};12
use macltok::{23
AttachmentUpload, ClientHandle, CreateRoomOptions, MediaId, MediaSize, MemberFilter,34
MessageContent, MirrorHandle, TimelineId, VerificationAction, VerificationTarget,@@ -98,6 +99,13 @@ pub enum Request {
9899
#[serde(default)]99100
typing: bool,100101
},102
+ GetAccountData {103
+ event_type: String,104
+ },105
+ SetAccountData {106
+ event_type: String,107
+ content: serde_json::Value,108
+ },101109
}102110
103111
impl Request {@@ -223,8 +231,10 @@ pub async fn dispatch(
223231
let room_id = parse_room_id(&room_id)?;224232
let event_id = parse_event_id(&event_id)?;225233
let key = key.trim();226
- if key.is_empty() || key.chars().count() > 32 {227
- return Err(CoreError::bad_request("key must be 1..32 characters"));234
+ if !valid_reaction_key(key) {235
+ return Err(CoreError::bad_request(236
+ "key must be 1..32 characters, or an mxc uri up to 255",237
+ ));228238
}229239
handle230240
.toggle_reaction(TimelineId::Room(room_id), event_id, key)@@ -277,11 +287,21 @@ pub async fn dispatch(
277287
Ok(Reply::Done)278288
}279289
Request::Verify { action } => {290
+ let action = match action.as_str() {291
+ "Accept" => VerificationAction::Accept,292
+ "Confirm" => VerificationAction::Confirm,293
+ "Reject" => VerificationAction::Reject,294
+ "Cancel" => VerificationAction::Cancel,295
+ _ => return Err(CoreError::bad_request("invalid verification action")),296
+ };280297
handle.verify(action).await?;281298
Ok(Reply::Done)282299
}283
- Request::Avatar { media_id } => {284
- let media_id = parse_media_id(media_id)?;300
+ Request::Avatar { mxc } => {301
+ if !mxc.starts_with("mxc://") {302
+ return Err(CoreError::bad_request("invalid mxc URI"));303
+ }304
+ let media_id = parse_media_id(mxc)?;285305
let size = MediaSize::Thumbnail {286306
width: AVATAR_SIZE,287307
height: AVATAR_SIZE,@@ -317,5 +337,67 @@ pub async fn dispatch(
317337
handle.set_typing(room_id, typing).await?;318338
Ok(Reply::Done)319339
}340
+ Request::GetAccountData { event_type } => {341
+ let event_type = account_data_type(&event_type)?;342
+ let content = handle.global_account_data(event_type).await?;343
+ let value = match content {344
+ Some(raw) => serde_json::from_str(raw.json().get())345
+ .map_err(|_| CoreError::bad_request("account data is not valid json"))?,346
+ None => serde_json::Value::Null,347
+ };348
+ Ok(Reply::Json(value))349
+ }350
+ Request::SetAccountData {351
+ event_type,352
+ content,353
+ } => {354
+ let event_type = account_data_type(&event_type)?;355
+ if !content.is_object() {356
+ return Err(CoreError::bad_request("content must be a json object"));357
+ }358
+ let raw = serde_json::value::to_raw_value(&content)359
+ .map_err(|_| CoreError::bad_request("content is not valid json"))?;360
+ handle361
+ .set_global_account_data(event_type, Raw::from_json(raw))362
+ .await?;363
+ Ok(Reply::Done)364
+ }365
+ }366
+}367
+368
+fn parse_media_id(media_id: String) -> Result<MediaId, CoreError> {369
+ if media_id.is_empty() || media_id.len() > 512 {370
+ return Err(CoreError::bad_request("invalid media_id"));371
+ }372
+ Ok(MediaId::from(media_id))373
+}374
+375
+fn valid_reaction_key(key: &str) -> bool {376
+ if key.starts_with("mxc://") {377
+ return key.len() <= 255;378
+ }379
+ !key.is_empty() && key.chars().count() <= 32380
+}381
+382
+fn account_data_type(event_type: &str) -> Result<GlobalAccountDataEventType, CoreError> {383
+ if event_type.is_empty() || event_type.len() > 255 {384
+ return Err(CoreError::bad_request("event_type must be 1..255 bytes"));385
+ }386
+ Ok(GlobalAccountDataEventType::from(event_type))387
+}388
+389
+#[cfg(test)]390
+mod tests {391
+ use super::*;392
+393
+ #[test]394
+ fn reaction_keys() {395
+ assert!(valid_reaction_key("๐"));396
+ assert!(!valid_reaction_key(""));397
+ assert!(!valid_reaction_key(&"a".repeat(33)));398
+ assert!(valid_reaction_key(399
+ "mxc://example.org/abcdef0123456789abcdef0123456789"400
+ ));401
+ assert!(!valid_reaction_key(&format!("mxc://{}", "a".repeat(250))));320402
}321403
}@@ -58,6 +58,7 @@ impl From<CoreError> for ApiError {
5858
fn from(error: CoreError) -> Self {5959
let status = match error.kind {6060
Failure::BadRequest => StatusCode::BAD_REQUEST,61
+ Failure::Unauthorized => StatusCode::UNAUTHORIZED,6162
Failure::NotFound => StatusCode::NOT_FOUND,6263
Failure::Conflict => StatusCode::CONFLICT,6364
Failure::Busy => StatusCode::TOO_MANY_REQUESTS,@@ -12,7 +12,7 @@ use futures_util::StreamExt;
1212
use macltok::Retain;1313
use malice_core::{1414
feed::subscribe,15
- login::{LoginInput, sanitise_auth_error},15
+ login::LoginInput,1616
request::{Reply, Request, dispatch},1717
};1818
use serde::{Deserialize, Serialize};@@ -112,6 +112,17 @@ struct TypingBody {
112112
typing: bool,113113
}114114
115
+#[derive(Debug, Deserialize)]116
+struct AccountDataQuery {117
+ event_type: String,118
+}119
+120
+#[derive(Debug, Deserialize)]121
+struct AccountDataBody {122
+ event_type: String,123
+ content: serde_json::Value,124
+}125
+115126
#[derive(Debug, Serialize)]116127
struct SoftLogoutInfo {117128
user_id: String,@@ -249,36 +260,22 @@ async fn post_login(
249260
headers: HeaderMap,250261
body: axum::Json<LoginInput>,251262
) -> Response {252
- let input = match body.0.validate(state.allow_http_homeserver()) {253
- Ok(input) => input,263
+ let username = body.0.username.trim().to_owned();264
+ let password = body.0.password.clone();265
+ let login = match body.0.into_login(state.allow_http_homeserver()) {266
+ Ok(login) => login,254267
Err(error) => return ApiError::from(error).into_response(),255268
};256269
let cookie_sid = session_sid(&headers);257
- let password = input.password.clone();258270
match state259
- .login(260
- cookie_sid.as_deref(),261
- input.homeserver,262
- input.username,263
- input.password,264
- )271
+ .login(cookie_sid.as_deref(), login, &username, password)265272
.await266273
{267274
Ok(user) => json_ok(268275
&serde_json::json!({ "user_id": user.user_id }),269276
Some(set_cookie_value(&user.sid, state.cookie_secure())),270277
),271
- Err(message) => {272
- let status = if message.contains("starting sync engine")273
- || message.contains("could not save the session")274
- || message.contains("another engine is using this session")275
- {276
- StatusCode::BAD_GATEWAY277
- } else {278
- StatusCode::UNAUTHORIZED279
- };280
- ApiError::new(status, sanitise_auth_error(&message, &password)).into_response()281
- }278
+ Err(error) => ApiError::from(error).into_response(),282279
}283280
}284281
@@ -720,6 +717,50 @@ async fn rooms_typing(
720717
)721718
}722719
720
+async fn get_account_data(721
+ State(state): State<AppState>,722
+ headers: HeaderMap,723
+ Query(query): Query<AccountDataQuery>,724
+) -> Response {725
+ let (user, _) = match auth(&state, &headers).await {726
+ Ok(pair) => pair,727
+ Err(error) => return error.into_response(),728
+ };729
+ respond(730
+ dispatch(731
+ &user.handle,732
+ &user.mirror,733
+ Request::GetAccountData {734
+ event_type: query.event_type,735
+ },736
+ )737
+ .await,738
+ )739
+}740
+741
+async fn set_account_data(742
+ State(state): State<AppState>,743
+ headers: HeaderMap,744
+ body: axum::Json<AccountDataBody>,745
+) -> Response {746
+ let (user, _) = match auth(&state, &headers).await {747
+ Ok(pair) => pair,748
+ Err(error) => return error.into_response(),749
+ };750
+ let body = body.0;751
+ respond(752
+ dispatch(753
+ &user.handle,754
+ &user.mirror,755
+ Request::SetAccountData {756
+ event_type: body.event_type,757
+ content: body.content,758
+ },759
+ )760
+ .await,761
+ )762
+}763
+723764
async fn get_events(State(state): State<AppState>, headers: HeaderMap) -> Response {724765
let (user, _) = match auth(&state, &headers).await {725766
Ok(pair) => pair,@@ -766,6 +807,10 @@ pub fn router(state: AppState, web_dir: Option<String>) -> Router {
766807
.route("/rooms/react", post(rooms_react))767808
.route("/rooms/read", post(rooms_read))768809
.route("/rooms/typing", post(rooms_typing))810
+ .route(811
+ "/account-data",812
+ get(get_account_data).post(set_account_data),813
+ )769814
.route("/profile", get(get_profile))770815
.route("/media", get(get_media))771816
.route("/message-media", get(get_message_media))@@ -15,6 +15,11 @@ use rand_core::{OsRng, RngCore};
1515
use sha2::{Digest, Sha256};1616
use tokio::{sync::Mutex, task::JoinHandle, time::timeout};1717
18
+use malice_core::{19
+ error::{CoreError, Failure},20
+ login::login_error,21
+};22
+1823
use crate::csrf::valid_sid;1924
2025
const AUTH_TIMEOUT: Duration = Duration::from_secs(20);@@ -180,34 +185,37 @@ impl AppState {
180185
pub async fn login(181186
&self,182187
cookie_sid: Option<&str>,183
- homeserver: String,184
- username: String,188
+ login: PasswordLogin,189
+ username: &str,185190
password: String,186
- ) -> Result<Arc<UserSession>, String> {191
+ ) -> Result<Arc<UserSession>, CoreError> {187192
if let Some(sid) = cookie_sid.filter(|sid| valid_sid(sid))188193
&& let Some(soft) = self.soft_logged_out(sid).await189194
{190
- if username_matches(&username, &soft.user_id) {195
+ if username_matches(username, &soft.user_id) {191196
let sessions = self.session_store(sid);192
- let session = login_with(soft.password_login(password), sessions.clone()).await?;197
+ let session = login_with(198
+ soft.password_login(password.clone()),199
+ &password,200
+ sessions.clone(),201
+ )202
+ .await?;193203
let _init = self.inner.init_lock.lock().await;194204
return self195205
.start_user_session(sid.to_owned(), session, sessions)196
- .await;206
+ .await207
+ .map_err(|error| CoreError::new(Failure::Upstream, error));197208
}198209
self.apply_retention(&self.session_store(sid), Retain::Nothing)199210
.await;200211
}201212
let sid = new_sid();202213
let sessions = self.session_store(&sid);203
- let session = login_with(204
- PasswordLogin::new(homeserver, username, password)205
- .with_device_display_name("malice web"),206
- sessions.clone(),207
- )208
- .await?;214
+ let session = login_with(login, &password, sessions.clone()).await?;209215
let _init = self.inner.init_lock.lock().await;210
- self.start_user_session(sid, session, sessions).await216
+ self.start_user_session(sid, session, sessions)217
+ .await218
+ .map_err(|error| CoreError::new(Failure::Upstream, error))211219
}212220
213221
async fn start_user_session(@@ -338,19 +346,16 @@ fn restore_error(error: AccountError) -> RestoreError {
338346
339347
async fn login_with(340348
login: PasswordLogin,349
+ password: &str,341350
sessions: Arc<dyn SessionStore>,342
-) -> Result<Session, String> {351
+) -> Result<Session, CoreError> {343352
match Session::login_password(login, sessions).await {344353
Ok(session) => Ok(session),345
- Err(AccountError::StoreInUse) => Err("another engine is using this session".to_owned()),346354
Err(AccountError::Unsaved { session, .. }) => session347355
.save()348356
.await349
- .map_err(|_| "could not save the session".to_owned()),350
- Err(error) => Err(format!(351
- "login failed: {}",352
- malice_core::login::describe_error(&error)353
- )),357
+ .map_err(|_| CoreError::new(Failure::Upstream, "could not save the session")),358
+ Err(error) => Err(login_error(&error, password)),354359
}355360
}356361
@@ -1851,9 +1851,7 @@ name = "malice-core"
18511851
version = "0.1.0"18521852
dependencies = [18531853
"futures-util",1854
- "http",18551854
"macltok",1856
- "ruma",18571855
"serde",18581856
"serde_json",18591857
]@@ -7,6 +7,11 @@ use macltok::{
77
SessionStatus, SessionStore, StateChange,88
};99
10
+use malice_core::{11
+ error::{CoreError, Failure},12
+ login::login_error,13
+};14
+1015
use crate::HostInner;1116
1217
pub struct Account {@@ -38,20 +43,6 @@ impl Account {
3843
}3944
}4045
41
-pub struct LoginFailure {42
- pub kind: &'static str,43
- pub message: String,44
-}45
-46
-impl LoginFailure {47
- fn new(kind: &'static str, message: impl Into<String>) -> Self {48
- Self {49
- kind,50
- message: message.into(),51
- }52
- }53
-}54
-5546
pub fn store_name(account: &str) -> String {5647
format!("malice-{account}")5748
}@@ -76,25 +67,16 @@ pub fn restore_error_kind(error: &AccountError) -> (&'static str, String) {
7667
7768
pub async fn login_with(7869
login: PasswordLogin,70
+ password: &str,7971
sessions: Arc<dyn SessionStore>,80
-) -> Result<Session, LoginFailure> {72
+) -> Result<Session, CoreError> {8173
match Session::login_password(login, sessions).await {8274
Ok(session) => Ok(session),83
- Err(AccountError::StoreInUse) => Err(LoginFailure::new(84
- "store_in_use",85
- "another engine is using this session",86
- )),8775
Err(AccountError::Unsaved { session, .. }) => session8876
.save()8977
.await90
- .map_err(|_| LoginFailure::new("upstream", "could not save the session")),91
- Err(error) => Err(LoginFailure::new(92
- "unauthorized",93
- format!(94
- "login failed: {}",95
- malice_core::login::describe_error(&error)96
- ),97
- )),78
+ .map_err(|_| CoreError::new(Failure::Upstream, "could not save the session")),79
+ Err(error) => Err(login_error(&error, password)),9880
}9981
}10082
@@ -9,8 +9,8 @@ use std::rc::Rc;
99
use std::sync::Arc;1010
1111
use macltok::{12
- EncryptedSessionStore, MediaId, MediaLocation, MediaSize, PasswordLogin, Restore, Retain,13
- Session, SessionStore, StoreConfig,12
+ EncryptedSessionStore, MediaId, MediaLocation, MediaSize, Restore, Retain, Session,13
+ SessionStore, StoreConfig,1414
};1515
use malice_core::error::CoreError;1616
use malice_core::feed::WireEvent;@@ -114,29 +114,26 @@ impl Host {
114114
username,115115
password,116116
};117
- let input = match input.validate(true) {118
- Ok(input) => input,117
+ let username = input.username.trim().to_owned();118
+ let password = input.password.clone();119
+ let login = match input.into_login(true) {120
+ Ok(login) => login,119121
Err(error) => return Err(core_error_value(error)),120122
};121
- let password = input.password.clone();122123
let sessions: Arc<dyn SessionStore> = Arc::new(EncryptedSessionStore::indexeddb(123124
StoreConfig::indexeddb(store_name(&account), passphrase),124125
));125126
let login = match Session::restore(sessions.clone()).await {126127
Ok(Some(Restore::SoftLoggedOut(soft)))127
- if username_matches(&input.username, &soft.user_id) =>128
+ if username_matches(&username, &soft.user_id) =>128129
{129
- soft.password_login(input.password)130
+ soft.password_login(password.clone())130131
}131
- _ => PasswordLogin::new(input.homeserver, input.username, input.password)132
- .with_device_display_name("malice web"),132
+ _ => login,133133
};134
- let session = match login_with(login, sessions.clone()).await {134
+ let session = match login_with(login, &password, sessions.clone()).await {135135
Ok(session) => session,136
- Err(failure) => {137
- let message = malice_core::login::sanitise_auth_error(&failure.message, &password);138
- return Err(error_value(failure.kind, message));139
- }136
+ Err(error) => return Err(core_error_value(error)),140137
};141138
match self.0.start_account(account, session, sessions).await {142139
Ok(user_id) => Ok(to_js_value(&serde_json::json!({ "user_id": user_id }))),@@ -14,6 +14,7 @@ pub fn error_value(kind: &str, message: impl AsRef<str>) -> JsValue {
1414
pub fn failure_kind(failure: Failure) -> &'static str {1515
match failure {1616
Failure::BadRequest => "bad_request",17
+ Failure::Unauthorized => "unauthorized",1718
Failure::NotFound => "not_found",1819
Failure::Conflict => "conflict",1920
Failure::Busy => "busy",@@ -8,6 +8,8 @@
88
"name": "malice-web",99
"version": "0.1.0",1010
"dependencies": {11
+ "@twemoji/svg": "^15.0.0",12
+ "emojibase-data": "^18.0.0",1113
"svelte": "^5.39.0"1214
},1315
"devDependencies": {@@ -15,6 +17,7 @@
1517
"@storybook/addon-mcp": "^10.6.1",1618
"@storybook/svelte-vite": "^10.6.1",1719
"@sveltejs/vite-plugin-svelte": "^4.0.4",20
+ "@types/node": "^22.20.5",1821
"storybook": "^10.6.1",1922
"typescript": "~6.0.3",2023
"vite": "^5.4.11"@@ -1993,6 +1996,12 @@
19931996
}19941997
}19951998
},1999
+ "node_modules/@twemoji/svg": {2000
+ "version": "15.0.0",2001
+ "resolved": "https://registry.npmjs.org/@twemoji/svg/-/svg-15.0.0.tgz",2002
+ "integrity": "sha512-ZSPef2B6nBaYnfgdTbAy4jgW95o7pi2xPGwGCU+WMTxo7J6B1lMPTWwSq/wTuiMq+N0khQ90CcvYp1wFoQpo/w==",2003
+ "license": "MIT"2004
+ },19962005
"node_modules/@tybys/wasm-util": {19972006
"version": "0.10.4",19982007
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",@@ -2042,6 +2051,16 @@
20422051
"dev": true,20432052
"license": "MIT"20442053
},2054
+ "node_modules/@types/node": {2055
+ "version": "22.20.5",2056
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",2057
+ "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",2058
+ "dev": true,2059
+ "license": "MIT",2060
+ "dependencies": {2061
+ "undici-types": "~6.21.0"2062
+ }2063
+ },20452064
"node_modules/@types/react": {20462065
"version": "19.3.0",20472066
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",@@ -2383,6 +2402,33 @@
23832402
"dev": true,23842403
"license": "MIT"23852404
},2405
+ "node_modules/emojibase": {2406
+ "version": "18.0.0",2407
+ "resolved": "https://registry.npmjs.org/emojibase/-/emojibase-18.0.0.tgz",2408
+ "integrity": "sha512-5PG+EEt5zQ9qvqdHZw61+T9iz96g+r621QS/w8jEzPg+3mjzAdjl8LPLc+e9ABv/yboNfMwEair7aK9qKnwAbQ==",2409
+ "license": "MIT",2410
+ "peer": true,2411
+ "engines": {2412
+ "node": ">=20.0.0"2413
+ },2414
+ "funding": {2415
+ "type": "ko-fi",2416
+ "url": "https://ko-fi.com/milesjohnson"2417
+ }2418
+ },2419
+ "node_modules/emojibase-data": {2420
+ "version": "18.0.0",2421
+ "resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-18.0.0.tgz",2422
+ "integrity": "sha512-TPPRRYv2zPd0jKI+pTpMquwWNZXYAsC3IVsCMeg2X8y8fEzKwOks4/5bz0YavF5r6d71aGPpb6oDnUEwSLLMoA==",2423
+ "license": "MIT",2424
+ "funding": {2425
+ "type": "ko-fi",2426
+ "url": "https://ko-fi.com/milesjohnson"2427
+ },2428
+ "peerDependencies": {2429
+ "emojibase": "*"2430
+ }2431
+ },23862432
"node_modules/esbuild": {23872433
"version": "0.28.2",23882434
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",@@ -3205,6 +3251,13 @@
32053251
"node": ">=14.17"32063252
}32073253
},3254
+ "node_modules/undici-types": {3255
+ "version": "6.21.0",3256
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",3257
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",3258
+ "dev": true,3259
+ "license": "MIT"3260
+ },32083261
"node_modules/unplugin": {32093262
"version": "2.3.11",32103263
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",@@ -14,13 +14,16 @@
1414
"build-storybook": "storybook build && chmod -R u+w storybook-static"1515
},1616
"dependencies": {17
+ "@twemoji/svg": "^15.0.0",18
+ "emojibase-data": "^18.0.0",1719
"svelte": "^5.39.0"1820
},1921
"devDependencies": {20
- "@sveltejs/vite-plugin-svelte": "^4.0.4",2122
"@storybook/addon-docs": "^10.6.1",2223
"@storybook/addon-mcp": "^10.6.1",2324
"@storybook/svelte-vite": "^10.6.1",25
+ "@sveltejs/vite-plugin-svelte": "^4.0.4",26
+ "@types/node": "^22.20.5",2427
"storybook": "^10.6.1",2528
"typescript": "~6.0.3",2629
"vite": "^5.4.11"@@ -43,13 +43,15 @@
4343
} from './lib/store';4444
import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';4545
import type { PendingMessage } from './lib/pending';46
+ import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';4647
import { notifyTyping, stopTyping } from './lib/typing';4748
import { formatDay } from './lib/format';4849
import { parseEncryptionState, parseOwnProfile, parseVerificationState } from './lib/types';49
- import type { MemberSummary, OwnProfile, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';50
+ import type { MemberSummary, OwnProfile, QuickReaction, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';5051
5152
let roomsLoading = false;5253
let booted = false;54
+ let wasOnline = false;5355
let search = '';5456
let sidebarOpen = false;5557
let membersOpen = false;@@ -84,6 +86,12 @@
8486
let spaceChildrenError: string | null = null;8587
let avatarByUser: Record<string, string | null> = {};8688
89
+ // account data is read from the local cache, which is empty on a fresh device until the first sync lands90
+ $: {91
+ const online = $connection === 'online';92
+ if (online && !wasOnline) void loadQuickReactions();93
+ wasOnline = online;94
+ }8795
$: selectedRoom = $rooms.find((r) => r.room_id === $selectedRoomId) ?? null;8896
$: spaceRooms = $rooms.filter((r) => r.space);8997
$: invites = $rooms.filter((r) => r.membership === 'Invited');@@ -204,6 +212,7 @@
204212
connectEvents();205213
void loadProfile();206214
void loadEncryption();215
+ void loadQuickReactions();207216
} else {208217
softLogout.set(session.soft_logout ?? null);209218
}@@ -258,6 +267,7 @@
258267
connectEvents();259268
void loadProfile();260269
void loadEncryption();270
+ void loadQuickReactions();261271
}262272
263273
async function doLogout(): Promise<void> {@@ -501,6 +511,14 @@
501511
}502512
}503513
514
+ async function changeQuickReactions(list: QuickReaction[]): Promise<void> {515
+ try {516
+ await saveQuickReactions(list);517
+ } catch (e) {518
+ pushBanner(e instanceof Error ? e.message : 'Could not save quick reactions');519
+ }520
+ }521
+504522
async function paginate(): Promise<void> {505523
const roomId = $selectedRoomId;506524
if (!roomId || paginateBusy) return;@@ -1066,6 +1084,9 @@
10661084
senderAvatar={senderAvatarFor(g.item.event.sender.user_id)}10671085
{reacting}10681086
onReact={(eventId, key) => void react(eventId, key)}1087
+ quickReactions={$quickReactions}1088
+ packs={[]}1089
+ onQuickChange={(list) => void changeQuickReactions(list)}10691090
/>10701091
{/if}10711092
{/each}@@ -1078,6 +1099,9 @@
10781099
senderAvatar={senderAvatarFor($sessionUser ?? '')}10791100
{reacting}10801101
onReact={(eventId, key) => void react(eventId, key)}1102
+ quickReactions={$quickReactions}1103
+ packs={[]}1104
+ onQuickChange={(list) => void changeQuickReactions(list)}10811105
/>10821106
{#if p.status === 'failed'}10831107
<div class="pending-actions">@@ -0,0 +1,32 @@
1
+<script lang="ts">2
+ import { twemojiUrl } from './twemoji';3
+4
+ export let emoji: string;5
+ export let size: number | string = 20;6
+ export let label: string | null = null;7
+8
+ let failed = false;9
+ $: emoji, (failed = false);10
+ $: dim = typeof size === 'number' ? `${size}px` : size;11
+ $: align = typeof size === 'number' ? 'middle' : '-0.25em';12
+</script>13
+14
+{#if failed}15
+ <span class="native" style={`font-size:${dim}`} aria-label={label ?? undefined}>{emoji}</span>16
+{:else}17
+ <img18
+ class="emoji"19
+ src={twemojiUrl(emoji)}20
+ alt={emoji}21
+ aria-label={label ?? undefined}22
+ style={`width:${dim};height:${dim};vertical-align:${align}`}23
+ draggable="false"24
+ loading="lazy"25
+ on:error={() => (failed = true)}26
+ />27
+{/if}28
+29
+<style>30
+ .emoji { display: inline-block; object-fit: contain; }31
+ .native { display: inline-block; line-height: 1; vertical-align: middle; }32
+</style>@@ -0,0 +1,12 @@
1
+<script lang="ts">2
+ import Emoji from './Emoji.svelte';3
+ import { isJumbo, segmentEmoji } from './twemoji';4
+5
+ export let text: string;6
+ export let jumbo = false;7
+8
+ $: segments = segmentEmoji(text);9
+ $: size = jumbo && isJumbo(segments) ? '2.4em' : '1.25em';10
+</script>11
+12
+{#each segments as s}{#if s.emoji}<Emoji emoji={s.text} {size} />{:else}{s.text}{/if}{/each}@@ -1,7 +1,7 @@
11
import type { Meta, StoryObj } from '@storybook/svelte-vite';2
-import { fn } from 'storybook/test';2
+import { expect, fn, userEvent } from 'storybook/test';33
import MessageRow from './MessageRow.svelte';4
-import type { TimelineContent, TimelineEvent } from './types';4
+import type { QuickReaction, TimelineContent, TimelineEvent } from './types';55
66
const alice = { user_id: '@alice:example.org', display_name: 'Alice', avatar: null, name_ambiguous: false };77
@@ -32,6 +32,12 @@ function text(body: string): TimelineContent {
3232
};3333
}3434
35
+const quick: QuickReaction[] = [36
+ { key: '๐', shortcode: 'thumbsup' },37
+ { key: '๐', shortcode: 'tada' },38
+ { key: 'mxc://example.org/rabbit', shortcode: 'rabbit' }39
+];40
+3541
const meta = {3642
title: 'Timeline/MessageRow',3743
component: MessageRow,@@ -40,7 +46,10 @@ const meta = {
4046
event: event(text('Curiouser and curiouser!')),4147
showSender: true,4248
reacting: false,43
- onReact: fn()49
+ onReact: fn(),50
+ quickReactions: quick,51
+ packs: [],52
+ onQuickChange: fn()4453
}4554
} satisfies Meta<typeof MessageRow>;4655
@@ -48,6 +57,29 @@ export default meta;
4857
type Story = StoryObj<typeof meta>;4958
5059
export const Text: Story = {};60
+export const ActionsOnSelect: Story = {61
+ play: async ({ args, canvas }) => {62
+ const toolbar = canvas.getByRole('toolbar');63
+ await expect(toolbar).not.toBeVisible();64
+ await userEvent.click(canvas.getByText('Curiouser and curiouser!'));65
+ await expect(toolbar).toBeVisible();66
+ await userEvent.click(canvas.getByLabelText('React :tada: to message from Alice'));67
+ await expect(args.onReact).toHaveBeenCalledWith('$event1', '๐');68
+ await userEvent.keyboard('{Escape}');69
+ (document.activeElement as HTMLElement).blur();70
+ await userEvent.unhover(canvas.getByText('Curiouser and curiouser!'));71
+ await expect(toolbar).not.toBeVisible();72
+ }73
+};74
+export const PickerFromToolbar: Story = {75
+ play: async ({ args, canvas }) => {76
+ await userEvent.click(canvas.getByText('Curiouser and curiouser!'));77
+ await userEvent.click(canvas.getByRole('button', { name: 'Add reaction to message from Alice' }));78
+ await userEvent.click(await canvas.findByLabelText('party popper', {}, { timeout: 10000 }));79
+ await expect(args.onReact).toHaveBeenCalledWith('$event1', '๐');80
+ await expect(canvas.queryByRole('dialog')).toBeNull();81
+ }82
+};5183
export const Continuation: Story = { args: { showSender: false } };5284
export const WithLink: Story = { args: { event: event(text('Notes are at https://example.org/rabbit-hole')) } };5385
export const Reactions: Story = {@@ -55,7 +87,8 @@ export const Reactions: Story = {
5587
event: event(text('Tea party at six'), {5688
reactions: [5789
{ key: '๐', count: 3, own: true, senders: [] },58
- { key: '๐ฎ', count: 1, own: false, senders: [] }90
+ { key: '๐ฎ', count: 1, own: false, senders: [] },91
+ { key: 'mxc://example.org/rabbit', count: 2, own: false, senders: [] }5992
]6093
})6194
}@@ -69,9 +102,26 @@ export const OwnFailed: Story = {
69102
export const UnableToDecrypt: Story = {70103
args: { event: event({ kind: 'UnableToDecrypt', cause: 'Unknown' }) }71104
};72
-export const Redacted: Story = { args: { event: event({ kind: 'Redacted' }) } };105
+export const Redacted: Story = {106
+ args: { event: event({ kind: 'Redacted' }) },107
+ play: async ({ canvas }) => {108
+ await expect(canvas.queryByRole('toolbar')).toBeNull();109
+ }110
+};73111
export const Membership: Story = {74112
args: {75113
event: event({ kind: 'Membership', user_id: '@hatter:example.org', display_name: 'Hatter', change: 'Joined' })76114
}77115
};116
+export const EmojiInBody: Story = {117
+ args: { event: event(text('Tea time ๐ซ with ๐ฉโ๐ป and ๐ณ๐ฟ at 1๏ธโฃ sharp')) }118
+};119
+export const JumboEmoji: Story = {120
+ args: { event: event(text('๐๐')) },121
+ play: async ({ canvasElement }) => {122
+ const imgs = canvasElement.querySelectorAll<HTMLImageElement>('.text img');123
+ await expect(imgs).toHaveLength(2);124
+ await expect(imgs[0]).toHaveAttribute('alt', '๐');125
+ await expect(imgs[0]).toHaveAttribute('draggable', 'false');126
+ }127
+};@@ -1,7 +1,11 @@
11
<script lang="ts">22
import MediaAttachmentView from './MediaAttachmentView.svelte';33
import MxcAvatar from './MxcAvatar.svelte';4
- import type { SendState, TimelineContent, TimelineEvent } from './types';4
+ import Emoji from './Emoji.svelte';5
+ import EmojiText from './EmojiText.svelte';6
+ import ReactionPicker from './ReactionPicker.svelte';7
+ import { mediaUrl } from './api';8
+ import type { ImagePack, QuickReaction, SendState, TimelineContent, TimelineEvent } from './types';59
import { senderLabel } from './types';610
import { formatClock, formatDay, initialsOf, senderHue } from './format';711
import { findUrls } from './urls';@@ -12,8 +16,19 @@
1216
export let reacting: boolean;1317
export let onReact: (eventId: string, key: string) => void;1418
export let senderAvatar: string | null = null;19
+ export let quickReactions: QuickReaction[] = [];20
+ export let packs: ImagePack[] = [];21
+ export let onQuickChange: (list: QuickReaction[]) => void = () => {};1522
16
- const QUICK_KEYS = ['๐', 'โค๏ธ', '๐', '๐', '๐ฎ', '๐ข'];23
+ const PICKER_WIDTH = 320;24
+ const PICKER_HEIGHT = 380;25
+ const PICKER_MARGIN = 8;26
+27
+ let row: HTMLDivElement;28
+ let pickerPop: HTMLDivElement | undefined;29
+ let selected = false;30
+ let pickerOpen = false;31
+ let pickerStyle = '';1732
1833
const MEMBERSHIP_LABELS: Record<string, string> = {1934
Joined: 'joined the room',@@ -44,6 +59,44 @@
4459
return '';4560
}4661
62
+ function toggleSelected(e: MouseEvent) {63
+ if (!interactive || (e.target as Element).closest('a, button, input, video, audio, .picker-pop')) return;64
+ selected = !selected;65
+ }66
+67
+ function deselectOutside(e: PointerEvent) {68
+ if (selected && !pickerOpen && !row.contains(e.target as Node)) selected = false;69
+ }70
+71
+ function openPicker(e: MouseEvent) {72
+ if (pickerOpen) {73
+ pickerOpen = false;74
+ return;75
+ }76
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();77
+ const height = Math.min(PICKER_HEIGHT, window.innerHeight - 2 * PICKER_MARGIN);78
+ const below = window.innerHeight - rect.bottom - PICKER_MARGIN >= height;79
+ const top = below ? rect.bottom + 4 : Math.max(PICKER_MARGIN, rect.top - height - 4);80
+ const maxLeft = window.innerWidth - PICKER_WIDTH - PICKER_MARGIN;81
+ const left = Math.max(PICKER_MARGIN, Math.min(rect.right - PICKER_WIDTH, maxLeft));82
+ pickerStyle = `top:${top}px;left:${left}px;height:${height}px`;83
+ selected = true;84
+ pickerOpen = true;85
+ }86
+87
+ function closePicker() {88
+ pickerOpen = false;89
+ }90
+91
+ function pick(key: string) {92
+ pickerOpen = false;93
+ onReact(event.event_id ?? '', key);94
+ }95
+96
+ function closeOnScroll(e: Event) {97
+ if (pickerOpen && !pickerPop?.contains(e.target as Node)) pickerOpen = false;98
+ }99
+47100
function actorLabel(userId: string, name: string | null): string {48101
return name ?? userId;49102
}@@ -106,7 +159,59 @@
106159
}107160
</script>108161
109
-<div class="msg" class:own={event.own} class:system={!interactive} data-item={itemId}>162
+<svelte:window163
+ on:pointerdown={deselectOutside}164
+ on:keydown={(e) => e.key === 'Escape' && (selected = false)}165
+ on:scroll|capture={closeOnScroll}166
+ on:resize={closePicker}167
+/>168
+169
+<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->170
+<div171
+ class="msg"172
+ class:own={event.own}173
+ class:system={!interactive}174
+ class:selected175
+ data-item={itemId}176
+ bind:this={row}177
+ on:click={toggleSelected}178
+>179
+ {#if interactive && event.event_id}180
+ <div class="actions" role="toolbar" aria-label={`Actions for message from ${displayName}`}>181
+ {#each quickReactions as r (r.key)}182
+ {@const label = r.shortcode ? `:${r.shortcode}:` : r.key}183
+ <button184
+ type="button"185
+ title={`React ${label}`}186
+ aria-label={`React ${label} to message from ${displayName}`}187
+ disabled={reacting}188
+ on:click={() => onReact(event.event_id ?? '', r.key)}189
+ >190
+ {#if r.key.startsWith('mxc://')}191
+ <img class="emote" src={mediaUrl(r.key) ?? ''} alt="" />192
+ {:else}193
+ <Emoji emoji={r.key} size={18} />194
+ {/if}195
+ </button>196
+ {/each}197
+ <button198
+ type="button"199
+ title="Add reaction"200
+ aria-label={`Add reaction to message from ${displayName}`}201
+ aria-haspopup="dialog"202
+ aria-expanded={pickerOpen}203
+ on:pointerdown|stopPropagation204
+ on:click={openPicker}205
+ >206
+ โบ+207
+ </button>208
+ </div>209
+ {#if pickerOpen}210
+ <div class="picker-pop" style={pickerStyle} bind:this={pickerPop}>211
+ <ReactionPicker {packs} quick={quickReactions} onPick={(key) => pick(key)} {onQuickChange} onClose={closePicker} />212
+ </div>213
+ {/if}214
+ {/if}110215
{#if showSender}111216
{#if senderAvatar}112217
<MxcAvatar mxc={senderAvatar} label={name} size={34} />@@ -141,7 +246,7 @@
141246
{#if event.reply_to}142247
<div class="reply-quote">143248
{#if event.reply_to.details.kind === 'Ready'}144
- Replying to {senderLabel(event.reply_to.details.sender)}: {replyPreview(event.reply_to.details.content)}249
+ Replying to {senderLabel(event.reply_to.details.sender)}: <EmojiText text={replyPreview(event.reply_to.details.content)} />145250
{:else if event.reply_to.details.kind === 'Pending'}146251
Loading replyโฆ147252
{:else}@@ -153,7 +258,7 @@
153258
{#if event.content.kind === 'Message'}154259
{@const m = event.content.message}155260
<p class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>156
- {#if m.kind === 'Emote'}* {displayName} {/if}{m.body}261
+ {#if m.kind === 'Emote'}* {displayName} {/if}<EmojiText text={m.body} jumbo={m.kind === 'Text'} />157262
</p>158263
{#if m.media}159264
<MediaAttachmentView attachment={m.media} />@@ -170,7 +275,7 @@
170275
</div>171276
{/if}172277
{:else if event.content.kind === 'Sticker'}173
- {#if event.content.body}<p class="text">{event.content.body}</p>{/if}278
+ {#if event.content.body}<p class="text"><EmojiText text={event.content.body} /></p>{/if}174279
{#if event.content.media}175280
<MediaAttachmentView attachment={event.content.media} />176281
{/if}@@ -191,13 +296,17 @@
191296
disabled={!event.event_id || reacting}192297
on:click={() => event.event_id && onReact(event.event_id, r.key)}193298
>194
- <span aria-hidden="true">{r.key}</span>299
+ {#if r.key.startsWith('mxc://')}300
+ <img class="emote" src={mediaUrl(r.key) ?? ''} alt="" />301
+ {:else}302
+ <Emoji emoji={r.key} size={16} />303
+ {/if}195304
<span class="count">{r.count}</span>196305
</button>197306
{/each}198307
</div>199308
{/if}200
- {#if interactive}309
+ {#if interactive && (sendLabel(event.send_state) || event.read_by.length > 0)}201310
<div class="msg-foot">202311
{#if sendLabel(event.send_state)}203312
<span class="sendstate" class:failed={event.send_state.kind === 'Failed'}>{sendLabel(event.send_state)}</span>@@ -205,31 +314,16 @@
205314
{#if event.read_by.length > 0}206315
<span class="readby" title={event.read_by.join(', ')}>โโ {event.read_by.length}</span>207316
{/if}208
- {#if event.event_id}209
- <span class="quick-react" role="group" aria-label={`React to message from ${displayName}`}>210
- {#each QUICK_KEYS as key (key)}211
- <button212
- type="button"213
- title={`React ${key}`}214
- aria-label={`React ${key} to message from ${displayName}`}215
- disabled={reacting}216
- on:click={() => onReact(event.event_id ?? '', key)}217
- >218
- {key}219
- </button>220
- {/each}221
- </span>222
- {/if}223317
</div>224318
{/if}225319
</div>226320
</div>227321
228322
<style>229
- .msg { display: flex; gap: 10px; padding: 3px 0; }230
- .msg:hover { background: rgba(255, 255, 255, 0.018); }231
- .msg:hover .hover-time { opacity: 1; }232
- .msg-foot { display: flex; align-items: center; gap: 8px; min-height: 18px; }323
+ .msg { position: relative; display: flex; gap: 10px; padding: 3px 0; }324
+ .msg:hover, .msg.selected { background: rgba(255, 255, 255, 0.018); }325
+ .msg:hover .hover-time, .msg.selected .hover-time { opacity: 1; }326
+ .msg-foot { display: flex; align-items: center; gap: 8px; }233327
.sendstate { font-size: 11px; color: var(--muted); }234328
.sendstate.failed { color: var(--danger); }235329
.link-previews { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; max-width: 420px; }@@ -289,18 +383,29 @@
289383
}290384
.reaction.mine { border-color: var(--accent); background: rgba(88, 101, 242, 0.16); }291385
.reaction:disabled { cursor: default; opacity: 0.85; }292
- .msg-foot { display: flex; align-items: center; gap: 8px; min-height: 18px; }293386
.readby { font-size: 11px; color: var(--muted); }294
- .msg:hover .quick-react, .msg:focus-within .quick-react { display: inline-flex; }295
- .quick-react button {387
+ .actions {388
+ position: absolute; top: -14px; right: 12px; z-index: 2;389
+ display: flex; gap: 2px;390
+ padding: 2px;391
+ background: var(--bg-soft);392
+ border: 1px solid var(--border);393
+ border-radius: 8px;394
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);395
+ opacity: 0;396
+ pointer-events: none;397
+ }398
+ .msg:hover .actions, .msg:focus-within .actions, .msg.selected .actions { opacity: 1; pointer-events: auto; }399
+ .actions button {296400
background: transparent;297401
border: 1px solid transparent;298402
border-radius: 6px;299403
cursor: pointer;300
- font-size: 13px;301
- padding: 0 3px;302
- opacity: 0.75;404
+ font-size: 14px;405
+ padding: 1px 5px;303406
}304
- .quick-react button:hover:not(:disabled) { opacity: 1; border-color: var(--border); background: var(--bg-soft); }305
- .quick-react button:disabled { opacity: 0.4; cursor: default; }407
+ .actions button:hover:not(:disabled) { border-color: var(--border); background: rgba(255, 255, 255, 0.06); }408
+ .actions button:disabled { opacity: 0.4; cursor: default; }409
+ .emote { width: 1.3em; height: 1.3em; object-fit: contain; vertical-align: middle; }410
+ .picker-pop { position: fixed; z-index: 60; width: 320px; max-width: calc(100vw - 16px); }306411
</style>@@ -0,0 +1,110 @@
1
+import type { Meta, StoryObj } from '@storybook/svelte-vite';2
+import { expect, fn, userEvent, waitFor } from 'storybook/test';3
+import ReactionPicker from './ReactionPicker.svelte';4
+import type { ImagePack, QuickReaction } from './types';5
+6
+const packs: ImagePack[] = [7
+ {8
+ id: 'user',9
+ display_name: 'My emotes',10
+ avatar_url: null,11
+ source: { kind: 'User' },12
+ images: [13
+ { shortcode: 'rabbit', url: 'mxc://example.org/rabbit', body: 'White rabbit', usage: ['emoticon'] },14
+ { shortcode: 'teapot', url: 'mxc://example.org/teapot', body: null, usage: ['emoticon', 'sticker'] }15
+ ]16
+ },17
+ {18
+ id: 'room',19
+ display_name: 'Tea party stickers',20
+ avatar_url: null,21
+ source: { kind: 'Room', room_id: '!tea:example.org', state_key: 'tea' },22
+ images: [{ shortcode: 'dormouse', url: 'mxc://example.org/dormouse', body: null, usage: ['sticker'] }]23
+ }24
+];25
+26
+const quick: QuickReaction[] = [27
+ { key: '๐', shortcode: 'thumbsup' },28
+ { key: '๐', shortcode: 'tada' }29
+];30
+31
+const meta = {32
+ title: 'Timeline/ReactionPicker',33
+ component: ReactionPicker,34
+ args: { packs, quick, onPick: fn(), onQuickChange: fn(), onClose: fn() }35
+} satisfies Meta<typeof ReactionPicker>;36
+37
+export default meta;38
+type Story = StoryObj<typeof meta>;39
+40
+export const Default: Story = {41
+ play: async ({ canvas }) => {42
+ await expect(canvas.getByRole('searchbox', { name: 'Search emoji' })).toHaveFocus();43
+ await expect(canvas.getByRole('button', { name: ':rabbit:' })).toBeVisible();44
+ await waitFor(() => expect(canvas.getByLabelText('grinning face')).toBeVisible(), { timeout: 10000 });45
+ }46
+};47
+48
+export const Search: Story = {49
+ play: async ({ canvas }) => {50
+ await waitFor(() => expect(canvas.getByLabelText('grinning face')).toBeVisible(), { timeout: 10000 });51
+ await userEvent.type(canvas.getByRole('searchbox'), 'rabbit');52
+ await expect(canvas.getByRole('button', { name: ':rabbit:' })).toBeVisible();53
+ await expect(canvas.queryByRole('button', { name: ':teapot:' })).toBeNull();54
+ await expect(canvas.queryByRole('button', { name: 'grinning face' })).toBeNull();55
+ await expect(canvas.getByRole('button', { name: 'rabbit face' })).toBeVisible();56
+ }57
+};58
+59
+export const NoMatches: Story = {60
+ play: async ({ canvas }) => {61
+ await waitFor(() => expect(canvas.getByLabelText('grinning face')).toBeVisible(), { timeout: 10000 });62
+ await userEvent.type(canvas.getByRole('searchbox'), 'zzzzqq');63
+ await expect(canvas.getByText(/No emoji match/)).toBeVisible();64
+ }65
+};66
+67
+export const PickEmoji: Story = {68
+ play: async ({ args, canvas }) => {69
+ await userEvent.click(await canvas.findByLabelText('party popper', {}, { timeout: 10000 }));70
+ await expect(args.onPick).toHaveBeenCalledWith('๐', 'party');71
+ }72
+};73
+74
+export const PickCustom: Story = {75
+ play: async ({ args, canvas }) => {76
+ await userEvent.click(canvas.getByRole('button', { name: ':rabbit:' }));77
+ await expect(args.onPick).toHaveBeenCalledWith('mxc://example.org/rabbit', 'rabbit');78
+ }79
+};80
+81
+export const CustomiseToggles: Story = {82
+ play: async ({ args, canvas }) => {83
+ await userEvent.click(canvas.getByRole('button', { name: 'Customise quick reactions' }));84
+ await expect(canvas.getByRole('group', { name: 'Quick reactions' })).toBeVisible();85
+ await userEvent.click(canvas.getByRole('button', { name: ':rabbit:' }));86
+ await expect(args.onQuickChange).toHaveBeenLastCalledWith([...quick, { key: 'mxc://example.org/rabbit', shortcode: 'rabbit' }]);87
+ await userEvent.click(canvas.getByRole('button', { name: 'Remove :thumbsup: from quick reactions' }));88
+ await expect(args.onQuickChange).toHaveBeenLastCalledWith([quick[1]]);89
+ await expect(args.onPick).not.toHaveBeenCalled();90
+ }91
+};92
+93
+const fullQuick: QuickReaction[] = ['๐', '๐', '๐', '๐', '๐', '๐
', '๐', '๐คฃ'].map((key) => ({ key, shortcode: null }));94
+95
+export const CustomiseFull: Story = {96
+ args: { quick: fullQuick },97
+ play: async ({ args, canvas }) => {98
+ await userEvent.click(canvas.getByRole('button', { name: 'Customise quick reactions' }));99
+ await expect(canvas.getByText('8/8')).toBeVisible();100
+ await expect(canvas.getByRole('button', { name: ':rabbit:' })).toBeDisabled();101
+ await expect(args.onQuickChange).not.toHaveBeenCalled();102
+ }103
+};104
+105
+export const EscapeCloses: Story = {106
+ play: async ({ args }) => {107
+ await userEvent.keyboard('{Escape}');108
+ await expect(args.onClose).toHaveBeenCalled();109
+ }110
+};@@ -0,0 +1,247 @@
1
+<script lang="ts">2
+ import { onMount } from 'svelte';3
+ import { mediaUrl } from './api';4
+ import Emoji from './Emoji.svelte';5
+ import { loadEmoji, searchEmoji } from './emoji';6
+ import type { EmojiGroup } from './emoji';7
+ import { MAX_QUICK_REACTIONS } from './types';8
+ import type { ImagePack, PackImage, QuickReaction } from './types';9
+10
+ export let packs: ImagePack[];11
+ export let quick: QuickReaction[];12
+ export let onPick: (key: string, shortcode: string | null) => void;13
+ export let onQuickChange: (list: QuickReaction[]) => void;14
+ export let onClose: () => void;15
+16
+ let root: HTMLDivElement;17
+ let input: HTMLInputElement;18
+ let query = '';19
+ let customising = false;20
+ let groups: EmojiGroup[] | null = null;21
+ let loadFailed = false;22
+23
+ onMount(() => {24
+ input.focus();25
+ loadEmoji().then(26
+ (g) => (groups = g),27
+ () => (loadFailed = true)28
+ );29
+ });30
+31
+ $: q = query.trim().toLowerCase().replace(/^:|:$/g, '');32
+ $: filteredPacks = packs33
+ .map((pack) => ({ pack, images: pack.images.filter((i) => imageMatches(i, q)) }))34
+ .filter((p) => p.images.length > 0);35
+ $: filteredGroups = searchEmoji(groups ?? [], q);36
+ $: full = quick.length >= MAX_QUICK_REACTIONS;37
+ $: nothingFound = q !== '' && groups !== null && filteredPacks.length === 0 && filteredGroups.length === 0;38
+39
+ function imageMatches(image: PackImage, needle: string): boolean {40
+ if (!needle) return true;41
+ return image.shortcode.toLowerCase().includes(needle) || (image.body ?? '').toLowerCase().includes(needle);42
+ }43
+44
+ function isQuick(key: string): boolean {45
+ return quick.some((r) => r.key === key);46
+ }47
+48
+ function choose(key: string, shortcode: string | null): void {49
+ if (!customising) {50
+ onPick(key, shortcode);51
+ return;52
+ }53
+ if (isQuick(key)) onQuickChange(quick.filter((r) => r.key !== key));54
+ else if (!full) onQuickChange([...quick, { key, shortcode }]);55
+ }56
+57
+ function quickLabel(r: QuickReaction): string {58
+ return r.shortcode ? `:${r.shortcode}:` : r.key;59
+ }60
+61
+ function onKeydown(e: KeyboardEvent): void {62
+ if (e.key !== 'Escape') return;63
+ e.stopPropagation();64
+ onClose();65
+ }66
+67
+ function closeOutside(e: PointerEvent): void {68
+ if (!root.contains(e.target as Node)) onClose();69
+ }70
+</script>71
+72
+<svelte:window on:pointerdown={closeOutside} />73
+74
+<div75
+ class="picker"76
+ role="dialog"77
+ tabindex="-1"78
+ aria-label={customising ? 'Customise quick reactions' : 'Pick a reaction'}79
+ bind:this={root}80
+ on:keydown={onKeydown}81
+>82
+ <input83
+ class="search"84
+ type="search"85
+ placeholder="Search emoji"86
+ aria-label="Search emoji"87
+ autocomplete="off"88
+ bind:this={input}89
+ bind:value={query}90
+ />91
+92
+ {#if customising}93
+ <div class="quick" role="group" aria-label="Quick reactions">94
+ <div class="quick-head">95
+ <span>Quick reactions</span>96
+ <span class="muted">{quick.length}/{MAX_QUICK_REACTIONS}</span>97
+ </div>98
+ <div class="quick-row">99
+ {#each quick as r (r.key)}100
+ <span class="quick-item">101
+ {#if r.key.startsWith('mxc://')}102
+ <img src={mediaUrl(r.key) ?? ''} alt={quickLabel(r)} />103
+ {:else}104
+ <Emoji emoji={r.key} size={18} />105
+ {/if}106
+ <button107
+ type="button"108
+ class="remove"109
+ aria-label={`Remove ${quickLabel(r)} from quick reactions`}110
+ on:click={() => onQuickChange(quick.filter((x) => x.key !== r.key))}111
+ >112
+ ร113
+ </button>114
+ </span>115
+ {:else}116
+ <span class="muted">None. Pick emoji below to add them.</span>117
+ {/each}118
+ </div>119
+ </div>120
+ {/if}121
+122
+ <div class="scroll" aria-busy={groups === null && !loadFailed}>123
+ {#each filteredPacks as { pack, images } (pack.id)}124
+ <section class="group" aria-label={pack.display_name}>125
+ <h3>{pack.display_name}</h3>126
+ <div class="grid">127
+ {#each images as image (image.url + image.shortcode)}128
+ <button129
+ type="button"130
+ class="cell"131
+ class:chosen={customising && isQuick(image.url)}132
+ title={`:${image.shortcode}:`}133
+ aria-label={`:${image.shortcode}:`}134
+ aria-pressed={customising ? isQuick(image.url) : undefined}135
+ disabled={customising && full && !isQuick(image.url)}136
+ on:click={() => choose(image.url, image.shortcode)}137
+ >138
+ <img src={mediaUrl(image.url) ?? ''} alt="" loading="lazy" />139
+ </button>140
+ {/each}141
+ </div>142
+ </section>143
+ {/each}144
+145
+ {#if loadFailed}146
+ <p class="status" role="alert">Could not load the emoji list.</p>147
+ {:else if groups === null}148
+ <p class="status" role="status">Loading emojiโฆ</p>149
+ {:else}150
+ {#each filteredGroups as g (g.name)}151
+ <section class="group" aria-label={g.name}>152
+ <h3>{g.name}</h3>153
+ <div class="grid">154
+ {#each g.emojis as e (e.emoji)}155
+ <button156
+ type="button"157
+ class="cell"158
+ class:chosen={customising && isQuick(e.emoji)}159
+ title={e.label}160
+ aria-label={e.label}161
+ aria-pressed={customising ? isQuick(e.emoji) : undefined}162
+ disabled={customising && full && !isQuick(e.emoji)}163
+ on:click={() => choose(e.emoji, e.shortcodes[0] ?? null)}164
+ >165
+ <Emoji emoji={e.emoji} size={24} />166
+ </button>167
+ {/each}168
+ </div>169
+ </section>170
+ {/each}171
+ {/if}172
+ {#if nothingFound}173
+ <p class="status" role="status">No emoji match "{query.trim()}".</p>174
+ {/if}175
+ </div>176
+177
+ <div class="foot">178
+ {#if customising}179
+ <span class="muted">{full ? 'Quick reactions are full. Remove one to add another.' : 'Select emoji to add or remove them.'}</span>180
+ {/if}181
+ <button type="button" class="mode" aria-pressed={customising} on:click={() => (customising = !customising)}>182
+ {customising ? 'Done' : 'Customise quick reactions'}183
+ </button>184
+ </div>185
+</div>186
+187
+<style>188
+ .picker {189
+ outline: none;190
+ display: flex; flex-direction: column; gap: 6px;191
+ width: 320px; height: 380px; max-width: 100%; max-height: 100%;192
+ padding: 8px;193
+ background: var(--panel);194
+ border: 1px solid var(--border);195
+ border-radius: 10px;196
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);197
+ }198
+ .search {199
+ background: var(--bg-soft);200
+ border: 1px solid var(--border);201
+ border-radius: 8px;202
+ color: var(--text);203
+ padding: 6px 10px;204
+ font-size: 13px;205
+ }206
+ .search:focus-visible { outline: none; border-color: var(--accent); }207
+ .muted { color: var(--muted); font-size: 12px; }208
+ .quick { border: 1px solid var(--border); border-radius: 8px; padding: 6px; background: var(--bg-soft); }209
+ .quick-head { display: flex; justify-content: space-between; font-size: 12px; font-weight: 650; margin-bottom: 4px; }210
+ .quick-row { display: flex; flex-wrap: wrap; gap: 4px; min-height: 28px; align-items: center; }211
+ .quick-item {212
+ display: inline-flex; align-items: center; gap: 2px;213
+ padding: 1px 2px 1px 6px;214
+ border: 1px solid var(--border); border-radius: 999px;215
+ font-size: 16px;216
+ }217
+ .quick-item img { width: 18px; height: 18px; object-fit: contain; }218
+ .remove {219
+ background: transparent; border: none; color: var(--muted);220
+ cursor: pointer; font-size: 14px; line-height: 1; padding: 2px 5px; border-radius: 999px;221
+ }222
+ .remove:hover { color: var(--danger); }223
+ .scroll { flex: 1; min-height: 0; overflow-y: auto; }224
+ .group h3 {225
+ margin: 6px 2px 2px; font-size: 11px; font-weight: 650;226
+ text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted);227
+ }228
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 2px; }229
+ .cell {230
+ display: grid; place-items: center;231
+ height: 32px; padding: 0;232
+ background: transparent; border: 1px solid transparent; border-radius: 6px;233
+ font-size: 20px; cursor: pointer; color: var(--text);234
+ }235
+ .cell img { width: 24px; height: 24px; object-fit: contain; }236
+ .cell:hover:not(:disabled), .cell:focus-visible { background: rgba(255, 255, 255, 0.08); border-color: var(--border); outline: none; }237
+ .cell.chosen { border-color: var(--accent); background: rgba(88, 101, 242, 0.16); }238
+ .cell:disabled { opacity: 0.3; cursor: default; }239
+ .status { color: var(--muted); font-size: 13px; text-align: center; margin: 16px 0; }240
+ .foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }241
+ .mode {242
+ margin-left: auto;243
+ background: transparent; border: 1px solid var(--border); border-radius: 6px;244
+ color: var(--text); font-size: 12px; padding: 3px 8px; cursor: pointer;245
+ }246
+ .mode:hover, .mode[aria-pressed='true'] { border-color: var(--accent); }247
+</style>@@ -36,6 +36,9 @@ export const closeRoom = (room_id: string) => transport.closeRoom(room_id);
3636
export const sendMessage = (room_id: string, body: string) => transport.sendMessage(room_id, body);3737
export const paginateRoom = (room_id: string) => transport.paginateRoom(room_id);3838
export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);39
+export const getAccountData = (eventType: string) => transport.getAccountData(eventType);40
+export const setAccountData = (eventType: string, content: Record<string, unknown>) =>41
+ transport.setAccountData(eventType, content);3942
4043
export function joinRoom(room: string): Promise<{ room_id: string }> {4144
return transport.joinRoom(room);@@ -0,0 +1,82 @@
1
+export interface EmojiEntry {2
+ emoji: string;3
+ label: string;4
+ tags: string[];5
+ shortcodes: string[];6
+}7
+8
+export interface EmojiGroup {9
+ name: string;10
+ emojis: EmojiEntry[];11
+}12
+13
+interface RawEmoji {14
+ hexcode: string;15
+ unicode: string;16
+ label: string;17
+ group?: number;18
+ order?: number;19
+ tags?: string[];20
+}21
+22
+const GROUP_NAMES: Record<number, string> = {23
+ 0: 'Smileys & emotion',24
+ 1: 'People & body',25
+ 3: 'Animals & nature',26
+ 4: 'Food & drink',27
+ 5: 'Travel & places',28
+ 6: 'Activities',29
+ 7: 'Objects',30
+ 8: 'Symbols',31
+ 9: 'Flags'32
+};33
+34
+let loading: Promise<EmojiGroup[]> | null = null;35
+36
+export function loadEmoji(): Promise<EmojiGroup[]> {37
+ loading ??= (async () => {38
+ const [compact, codes] = await Promise.all([39
+ import('emojibase-data/en/compact.json'),40
+ import('emojibase-data/en/shortcodes/emojibase.json')41
+ ]);42
+ const shortcodes = codes.default as Record<string, string | string[]>;43
+ const byGroup = new Map<number, EmojiEntry[]>();44
+ for (const raw of compact.default as RawEmoji[]) {45
+ if (raw.group === undefined || !(raw.group in GROUP_NAMES)) continue;46
+ const code = shortcodes[raw.hexcode];47
+ const entry: EmojiEntry = {48
+ emoji: raw.unicode,49
+ label: raw.label,50
+ tags: raw.tags ?? [],51
+ shortcodes: code === undefined ? [] : Array.isArray(code) ? code : [code]52
+ };53
+ const list = byGroup.get(raw.group) ?? [];54
+ list.push(entry);55
+ byGroup.set(raw.group, list);56
+ }57
+ return Object.entries(GROUP_NAMES).flatMap(([id, name]) => {58
+ const emojis = byGroup.get(Number(id));59
+ return emojis ? [{ name, emojis }] : [];60
+ });61
+ })().catch((e) => {62
+ loading = null;63
+ throw e;64
+ });65
+ return loading;66
+}67
+68
+export function matchesEmoji(entry: EmojiEntry, query: string): boolean {69
+ const q = query.trim().toLowerCase().replace(/^:|:$/g, '');70
+ if (!q) return true;71
+ return (72
+ entry.label.toLowerCase().includes(q) ||73
+ entry.tags.some((t) => t.toLowerCase().includes(q)) ||74
+ entry.shortcodes.some((s) => s.toLowerCase().includes(q))75
+ );76
+}77
+78
+export function searchEmoji(groups: EmojiGroup[], query: string): EmojiGroup[] {79
+ return groups80
+ .map((g) => ({ name: g.name, emojis: g.emojis.filter((e) => matchesEmoji(e, query)) }))81
+ .filter((g) => g.emojis.length > 0);82
+}@@ -0,0 +1,51 @@
1
+import { writable } from 'svelte/store';2
+import { getAccountData, setAccountData } from './api';3
+import { MAX_QUICK_REACTIONS } from './types';4
+import type { QuickReaction } from './types';5
+6
+const EVENT_TYPE = 'dev.malice.quick_reactions';7
+8
+export const DEFAULT_QUICK_REACTIONS: QuickReaction[] = [9
+ { key: '๐', shortcode: 'thumbsup' },10
+ { key: 'โค๏ธ', shortcode: 'heart' },11
+ { key: '๐', shortcode: 'joy' },12
+ { key: '๐', shortcode: 'tada' },13
+ { key: '๐ฎ', shortcode: 'open_mouth' },14
+ { key: '๐ข', shortcode: 'cry' }15
+];16
+17
+export const quickReactions = writable<QuickReaction[]>(DEFAULT_QUICK_REACTIONS);18
+19
+function parse(raw: unknown): QuickReaction[] | null {20
+ if (typeof raw !== 'object' || raw === null) return null;21
+ const list = (raw as Record<string, unknown>)['reactions'];22
+ if (!Array.isArray(list)) return null;23
+ const seen = new Set<string>();24
+ const out: QuickReaction[] = [];25
+ for (const item of list) {26
+ if (typeof item !== 'object' || item === null) continue;27
+ const { key, shortcode } = item as Record<string, unknown>;28
+ if (typeof key !== 'string' || !key || seen.has(key)) continue;29
+ seen.add(key);30
+ out.push({ key, shortcode: typeof shortcode === 'string' ? shortcode : null });31
+ }32
+ return out.slice(0, MAX_QUICK_REACTIONS);33
+}34
+35
+export async function loadQuickReactions(): Promise<void> {36
+ try {37
+ quickReactions.set(parse(await getAccountData(EVENT_TYPE)) ?? DEFAULT_QUICK_REACTIONS);38
+ } catch {39
+ quickReactions.set(DEFAULT_QUICK_REACTIONS);40
+ }41
+}42
+43
+export async function saveQuickReactions(list: QuickReaction[]): Promise<void> {44
+ const reactions = list.slice(0, MAX_QUICK_REACTIONS);45
+ await setAccountData(EVENT_TYPE, { reactions });46
+ quickReactions.set(reactions);47
+}48
+49
+export function resetQuickReactions(): void {50
+ quickReactions.set(DEFAULT_QUICK_REACTIONS);51
+}@@ -2,6 +2,7 @@ import { writable } from 'svelte/store';
22
import { getSession, subscribeEvents } from './api';33
import type { SoftLogout } from './api';44
import { isRecord } from './guards';5
+import { resetQuickReactions } from './quick-reactions';56
import {67
parseEncryptionState,78
parseRoomList,@@ -56,6 +57,7 @@ export function resetClientState(): void {
5657
encryption.set(null);5758
verification.set(null);5859
banners.set([]);60
+ resetQuickReactions();5961
}6062
6163
function applyTimelinePayload(roomId: string, payload: Record<string, unknown>): void {@@ -140,6 +140,23 @@ export class HttpTransport implements Transport {
140140
return json(res, 'Could not load profile');141141
}142142
143
+ async getAccountData(eventType: string): Promise<unknown | null> {144
+ const res = await fetch(`/api/account-data?event_type=${encodeURIComponent(eventType)}`, {145
+ credentials: 'same-origin'146
+ });147
+ return json(res, 'Could not load account data');148
+ }149
+150
+ async setAccountData(eventType: string, content: Record<string, unknown>): Promise<void> {151
+ const res = await fetch('/api/account-data', {152
+ method: 'POST',153
+ credentials: 'same-origin',154
+ headers: { 'content-type': 'application/json' },155
+ body: JSON.stringify({ event_type: eventType, content })156
+ });157
+ return json(res, 'Could not save account data');158
+ }159
+143160
async getSpaceChildren(room_id: string): Promise<{ room_ids: string[] }> {144161
const res = await fetch(`/api/spaces/children?room_id=${encodeURIComponent(room_id)}`, {145162
credentials: 'same-origin'@@ -245,6 +245,12 @@ export class WorkerTransport implements Transport {
245245
getProfile(): Promise<{ display_name: string | null; avatar_url: string | null }> {246246
return this.client.call('profile');247247
}248
+ getAccountData(eventType: string): Promise<unknown | null> {249
+ return this.client.call('get_account_data', { event_type: eventType });250
+ }251
+ setAccountData(eventType: string, content: Record<string, unknown>): Promise<void> {252
+ return this.client.call('set_account_data', { event_type: eventType, content });253
+ }248254
getSpaceChildren(room_id: string): Promise<{ room_ids: string[] }> {249255
return this.client.call('space_children', { room_id });250256
}@@ -38,6 +38,8 @@ export interface Transport {
3838
sendMessage(room_id: string, body: string): Promise<void>;3939
paginateRoom(room_id: string): Promise<void>;4040
setTyping(room_id: string, typing: boolean): Promise<void>;41
+ getAccountData(eventType: string): Promise<unknown | null>;42
+ setAccountData(eventType: string, content: Record<string, unknown>): Promise<void>;4143
joinRoom(room: string): Promise<{ room_id: string }>;4244
createRoom(name: string, topic?: string): Promise<{ room_id: string }>;4345
leaveRoom(room_id: string): Promise<void>;@@ -0,0 +1,48 @@
1
+const ZWJ = 0x200d;2
+const VS16 = 0xfe0f;3
+4
+export function twemojiUrl(emoji: string): string {5
+ const points = Array.from(emoji, (c) => c.codePointAt(0) ?? 0);6
+ const kept = points.includes(ZWJ) ? points : points.filter((p) => p !== VS16);7
+ return `/twemoji/${kept.map((p) => p.toString(16)).join('-')}.svg`;8
+}9
+10
+export interface TextSegment {11
+ text: string;12
+ emoji: boolean;13
+}14
+15
+const MAYBE_EMOJI = /\p{Extended_Pictographic}|\p{Regional_Indicator}|\u20E3/u;16
+const PICTOGRAPHIC = /\p{Extended_Pictographic}/u;17
+const EMOJI_PRESENTATION = /\p{Emoji_Presentation}/u;18
+const KEYCAP = /^[0-9#*]\uFE0F\u20E3$/;19
+const FLAG = /^\p{Regional_Indicator}{2}$/u;20
+21
+let segmenter: Intl.Segmenter | null = null;22
+23
+function isEmojiGrapheme(g: string): boolean {24
+ if (KEYCAP.test(g) || FLAG.test(g)) return true;25
+ return PICTOGRAPHIC.test(g) && (EMOJI_PRESENTATION.test(g) || g.includes(String.fromCodePoint(VS16)) || g.includes(String.fromCodePoint(ZWJ)));26
+}27
+28
+export function segmentEmoji(text: string): TextSegment[] {29
+ if (!MAYBE_EMOJI.test(text)) return [{ text, emoji: false }];30
+ segmenter ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' });31
+ const out: TextSegment[] = [];32
+ for (const { segment } of segmenter.segment(text)) {33
+ const emoji = isEmojiGrapheme(segment);34
+ const last = out[out.length - 1];35
+ if (last && !emoji && !last.emoji) last.text += segment;36
+ else out.push({ text: segment, emoji });37
+ }38
+ return out;39
+}40
+41
+export function isJumbo(segments: TextSegment[]): boolean {42
+ let count = 0;43
+ for (const s of segments) {44
+ if (s.emoji) count += 1;45
+ else if (s.text.trim() !== '') return false;46
+ }47
+ return count >= 1 && count <= 3;48
+}@@ -647,3 +647,23 @@ export function parseTimelineList(raw: unknown): TimelineItem[] {
647647
}648648
return out;649649
}650
+651
+export type PackUsage = 'emoticon' | 'sticker';652
+export interface PackImage {653
+ shortcode: string;654
+ url: string;655
+ body: string | null;656
+ usage: PackUsage[];657
+}658
+export interface ImagePack {659
+ id: string;660
+ display_name: string;661
+ avatar_url: string | null;662
+ source: { kind: 'User' } | { kind: 'Room'; room_id: string; state_key: string };663
+ images: PackImage[];664
+}665
+export const MAX_QUICK_REACTIONS = 8;666
+export interface QuickReaction {667
+ key: string;668
+ shortcode: string | null;669
+}@@ -467,7 +467,7 @@ sw.addEventListener('fetch', (event) => {
467467
return;468468
}469469
}470
- if (url.pathname.startsWith('/assets/')) {470
+ if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/twemoji/')) {471471
event.respondWith(cacheFirst(event.request));472472
return;473473
}@@ -0,0 +1,54 @@
1
+import assert from 'node:assert/strict';2
+import { test } from 'node:test';3
+import { isJumbo, segmentEmoji, twemojiUrl } from '../src/lib/twemoji.ts';4
+5
+const emojiOf = (text: string) => segmentEmoji(text).filter((s) => s.emoji).map((s) => s.text);6
+7
+test('plain text takes the fast path', () => {8
+ assert.deepEqual(segmentEmoji('hello world 123 #1 *'), [{ text: 'hello world 123 #1 *', emoji: false }]);9
+});10
+11
+test('splits text and emoji', () => {12
+ assert.deepEqual(segmentEmoji('hi ๐ there'), [13
+ { text: 'hi ', emoji: false },14
+ { text: '๐', emoji: true },15
+ { text: ' there', emoji: false }16
+ ]);17
+});18
+19
+test('keeps ZWJ sequences whole', () => {20
+ assert.deepEqual(emojiOf('a ๐ฉโ๐ป b ๐จโ๐ฉโ๐งโ๐ฆ'), ['๐ฉโ๐ป', '๐จโ๐ฉโ๐งโ๐ฆ']);21
+});22
+23
+test('keeps skin tones whole', () => {24
+ assert.deepEqual(emojiOf('๐๐ฝ'), ['๐๐ฝ']);25
+});26
+27
+test('treats a regional indicator pair as one flag', () => {28
+ assert.deepEqual(emojiOf('๐ณ๐ฟ and ๐ฏ๐ต'), ['๐ณ๐ฟ', '๐ฏ๐ต']);29
+});30
+31
+test('keycaps need FE0F and 20E3', () => {32
+ assert.deepEqual(emojiOf('1๏ธโฃ #๏ธโฃ'), ['1๏ธโฃ', '#๏ธโฃ']);33
+ assert.deepEqual(emojiOf('1 # *'), []);34
+});35
+36
+test('text presentation symbols stay text without FE0F', () => {37
+ assert.deepEqual(emojiOf('ยฉ ยฎ โบ'), []);38
+ assert.deepEqual(emojiOf('โบ๏ธ'), ['โบ๏ธ']);39
+});40
+41
+test('jumbo only for one to three emoji', () => {42
+ assert.equal(isJumbo(segmentEmoji('๐')), true);43
+ assert.equal(isJumbo(segmentEmoji('๐ ๐๐')), true);44
+ assert.equal(isJumbo(segmentEmoji('๐๐๐๐')), false);45
+ assert.equal(isJumbo(segmentEmoji('hi ๐')), false);46
+ assert.equal(isJumbo(segmentEmoji('hello')), false);47
+});48
+49
+test('twemojiUrl follows the filename rule', () => {50
+ assert.equal(twemojiUrl('๐'), '/twemoji/1f389.svg');51
+ assert.equal(twemojiUrl('โค๏ธ'), '/twemoji/2764.svg');52
+ assert.equal(twemojiUrl('๐จโโ๏ธ'), '/twemoji/1f468-200d-2695-fe0f.svg');53
+ assert.equal(twemojiUrl('1๏ธโฃ'), '/twemoji/31-20e3.svg');54
+});@@ -7,7 +7,7 @@
77
"skipLibCheck": true,88
"isolatedModules": true,99
"resolveJsonModule": true,10
- "types": ["vite/client"]10
+ "types": ["vite/client", "node"]1111
},1212
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"],1313
"exclude": ["src/worker/**", "src/sw.ts"]@@ -1,3 +1,6 @@
1
+import { readdirSync, readFileSync } from 'node:fs';2
+import { join } from 'node:path';3
+import { fileURLToPath } from 'node:url';14
import { defineConfig } from 'vite';25
import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte';36
@@ -13,7 +16,9 @@ function precacheManifestPlugin(): import('vite').Plugin {
1316
name: 'malice-sw-precache-manifest',1417
generateBundle(_options, bundle) {1518
const urls = Object.keys(bundle)16
- .filter((fileName) => fileName !== 'sw.js' && fileName !== '.vite/manifest.json')19
+ .filter(20
+ (fileName) => fileName !== 'sw.js' && fileName !== '.vite/manifest.json' && !fileName.startsWith('twemoji/')21
+ )1722
.map((fileName) => `/${fileName}`);1823
const swChunk = bundle['sw.js'];1924
if (swChunk && swChunk.type === 'chunk') {@@ -23,10 +28,36 @@ function precacheManifestPlugin(): import('vite').Plugin {
2328
};2429
}2530
31
+function twemojiPlugin(): import('vite').Plugin {32
+ const dir = fileURLToPath(new URL('./node_modules/@twemoji/svg', import.meta.url));33
+ const svgs = () => readdirSync(dir).filter((f) => f.endsWith('.svg'));34
+ return {35
+ name: 'malice-twemoji',36
+ configureServer(server) {37
+ server.middlewares.use('/twemoji', (req, res, next) => {38
+ const name = decodeURIComponent((req.url ?? '').split('?')[0]).replace(/^\//, '');39
+ if (!/^[0-9a-f-]+\.svg$/.test(name)) return next();40
+ try {41
+ res.setHeader('Content-Type', 'image/svg+xml');42
+ res.end(readFileSync(join(dir, name)));43
+ } catch {44
+ res.statusCode = 404;45
+ res.end();46
+ }47
+ });48
+ },49
+ generateBundle() {50
+ for (const file of svgs()) {51
+ this.emitFile({ type: 'asset', fileName: `twemoji/${file}`, source: readFileSync(join(dir, file)) });52
+ }53
+ }54
+ };55
+}56
+2657
export default defineConfig(({ mode }) => {2758
const device = mode === 'device';2859
return {29
- plugins: [svelte({ preprocess: vitePreprocess() }), ...(device ? [precacheManifestPlugin()] : [])],60
+ plugins: [svelte({ preprocess: vitePreprocess() }), twemojiPlugin(), ...(device ? [precacheManifestPlugin()] : [])],3061
assetsInclude: ['**/*.wasm'],3162
resolve: {3263
alias: {