diff --git a/.prettierrc.js b/.prettierrc.js index ae7b27fbfa2fd96a0c3b4dd4fe2a529452e1c4ff..ac8ca3531a0457d3a6e853a8cf51830cab284daa 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -3,7 +3,7 @@ module.exports = { "useTabs": true, "trailingComma": "all", "jsxBracketSameLine": true, - "importOrder": ["/(lib)", "/(redux)", "/(context)", "/(ui|common)|.svg$", "^[./]"], + "importOrder": ["preact|classnames|.scss$", "/(lib)", "/(redux)", "/(context)", "/(ui|common)|.svg$", "^[./]"], "importOrderSeparation": true, } \ No newline at end of file diff --git a/src/assets/emojis.ts b/src/assets/emojis.ts index 33a777e346f92de8e140118f7cd03eaab74715f8..1f58692783f403bcf266a97a2885bf9549d2b120 100644 --- a/src/assets/emojis.ts +++ b/src/assets/emojis.ts @@ -1 +1,1850 @@ -export const emojiDictionary={"100":"💯","1234":"🔢","grinning":"😀","smiley":"😃","smile":"😄","grin":"ðŸ˜","laughing":"😆","satisfied":"😆","sweat_smile":"😅","rofl":"🤣","joy":"😂","slightly_smiling_face":"🙂","upside_down_face":"🙃","wink":"😉","blush":"😊","innocent":"😇","smiling_face_with_three_hearts":"🥰","heart_eyes":"ðŸ˜","star_struck":"🤩","kissing_heart":"😘","kissing":"😗","relaxed":"☺ï¸","kissing_closed_eyes":"😚","kissing_smiling_eyes":"😙","smiling_face_with_tear":"🥲","yum":"😋","stuck_out_tongue":"😛","stuck_out_tongue_winking_eye":"😜","zany_face":"🤪","stuck_out_tongue_closed_eyes":"ðŸ˜","money_mouth_face":"🤑","hugs":"🤗","hand_over_mouth":"ðŸ¤","shushing_face":"🤫","thinking":"🤔","zipper_mouth_face":"ðŸ¤","raised_eyebrow":"🤨","neutral_face":"ðŸ˜","expressionless":"😑","no_mouth":"😶","smirk":"ðŸ˜","unamused":"😒","roll_eyes":"🙄","grimacing":"😬","lying_face":"🤥","relieved":"😌","pensive":"😔","sleepy":"😪","drooling_face":"🤤","sleeping":"😴","mask":"😷","face_with_thermometer":"🤒","face_with_head_bandage":"🤕","nauseated_face":"🤢","vomiting_face":"🤮","sneezing_face":"🤧","hot_face":"🥵","cold_face":"🥶","woozy_face":"🥴","dizzy_face":"😵","exploding_head":"🤯","cowboy_hat_face":"🤠","partying_face":"🥳","disguised_face":"🥸","sunglasses":"😎","nerd_face":"🤓","monocle_face":"ðŸ§","confused":"😕","worried":"😟","slightly_frowning_face":"ðŸ™","frowning_face":"☹ï¸","open_mouth":"😮","hushed":"😯","astonished":"😲","flushed":"😳","pleading_face":"🥺","frowning":"😦","anguished":"😧","fearful":"😨","cold_sweat":"😰","disappointed_relieved":"😥","cry":"😢","sob":"ðŸ˜","scream":"😱","confounded":"😖","persevere":"😣","disappointed":"😞","sweat":"😓","weary":"😩","tired_face":"😫","yawning_face":"🥱","triumph":"😤","rage":"😡","pout":"😡","angry":"😠","cursing_face":"🤬","smiling_imp":"😈","imp":"👿","skull":"💀","skull_and_crossbones":"☠ï¸","hankey":"💩","poop":"💩","shit":"💩","clown_face":"🤡","japanese_ogre":"👹","japanese_goblin":"👺","ghost":"👻","alien":"👽","space_invader":"👾","robot":"🤖","smiley_cat":"😺","smile_cat":"😸","joy_cat":"😹","heart_eyes_cat":"😻","smirk_cat":"😼","kissing_cat":"😽","scream_cat":"🙀","crying_cat_face":"😿","pouting_cat":"😾","see_no_evil":"🙈","hear_no_evil":"🙉","speak_no_evil":"🙊","kiss":"💋","love_letter":"💌","cupid":"💘","gift_heart":"ðŸ’","sparkling_heart":"💖","heartpulse":"💗","heartbeat":"💓","revolving_hearts":"💞","two_hearts":"💕","heart_decoration":"💟","heavy_heart_exclamation":"â£ï¸","broken_heart":"💔","heart":"â¤ï¸","orange_heart":"🧡","yellow_heart":"💛","green_heart":"💚","blue_heart":"💙","purple_heart":"💜","brown_heart":"🤎","black_heart":"🖤","white_heart":"ðŸ¤","anger":"💢","boom":"💥","collision":"💥","dizzy":"💫","sweat_drops":"💦","dash":"💨","hole":"🕳ï¸","bomb":"💣","speech_balloon":"💬","eye_speech_bubble":"ðŸ‘ï¸â€ðŸ—¨ï¸","left_speech_bubble":"🗨ï¸","right_anger_bubble":"🗯ï¸","thought_balloon":"ðŸ’","zzz":"💤","wave":"👋","raised_back_of_hand":"🤚","raised_hand_with_fingers_splayed":"ðŸ–ï¸","hand":"✋","raised_hand":"✋","vulcan_salute":"🖖","ok_hand":"👌","pinched_fingers":"🤌","pinching_hand":"ðŸ¤","v":"✌ï¸","crossed_fingers":"🤞","love_you_gesture":"🤟","metal":"🤘","call_me_hand":"🤙","point_left":"👈","point_right":"👉","point_up_2":"👆","middle_finger":"🖕","fu":"🖕","point_down":"👇","point_up":"â˜ï¸","+1":"ðŸ‘","thumbsup":"ðŸ‘","-1":"👎","thumbsdown":"👎","fist_raised":"✊","fist":"✊","fist_oncoming":"👊","facepunch":"👊","punch":"👊","fist_left":"🤛","fist_right":"🤜","clap":"ðŸ‘","raised_hands":"🙌","open_hands":"ðŸ‘","palms_up_together":"🤲","handshake":"ðŸ¤","pray":"ðŸ™","writing_hand":"âœï¸","nail_care":"💅","selfie":"🤳","muscle":"💪","mechanical_arm":"🦾","mechanical_leg":"🦿","leg":"🦵","foot":"🦶","ear":"👂","ear_with_hearing_aid":"🦻","nose":"👃","brain":"🧠","anatomical_heart":"🫀","lungs":"ðŸ«","tooth":"🦷","bone":"🦴","eyes":"👀","eye":"ðŸ‘ï¸","tongue":"👅","lips":"👄","baby":"👶","child":"🧒","boy":"👦","girl":"👧","adult":"🧑","blond_haired_person":"👱","man":"👨","bearded_person":"🧔","red_haired_man":"👨â€ðŸ¦°","curly_haired_man":"👨â€ðŸ¦±","white_haired_man":"👨â€ðŸ¦³","bald_man":"👨â€ðŸ¦²","woman":"👩","red_haired_woman":"👩â€ðŸ¦°","person_red_hair":"🧑â€ðŸ¦°","curly_haired_woman":"👩â€ðŸ¦±","person_curly_hair":"🧑â€ðŸ¦±","white_haired_woman":"👩â€ðŸ¦³","person_white_hair":"🧑â€ðŸ¦³","bald_woman":"👩â€ðŸ¦²","person_bald":"🧑â€ðŸ¦²","blond_haired_woman":"👱â€â™€ï¸","blonde_woman":"👱â€â™€ï¸","blond_haired_man":"👱â€â™‚ï¸","older_adult":"🧓","older_man":"👴","older_woman":"👵","frowning_person":"ðŸ™","frowning_man":"ðŸ™â€â™‚ï¸","frowning_woman":"ðŸ™â€â™€ï¸","pouting_face":"🙎","pouting_man":"🙎â€â™‚ï¸","pouting_woman":"🙎â€â™€ï¸","no_good":"🙅","no_good_man":"🙅â€â™‚ï¸","ng_man":"🙅â€â™‚ï¸","no_good_woman":"🙅â€â™€ï¸","ng_woman":"🙅â€â™€ï¸","ok_person":"🙆","ok_man":"🙆â€â™‚ï¸","ok_woman":"🙆â€â™€ï¸","tipping_hand_person":"ðŸ’","information_desk_person":"ðŸ’","tipping_hand_man":"ðŸ’â€â™‚ï¸","sassy_man":"ðŸ’â€â™‚ï¸","tipping_hand_woman":"ðŸ’â€â™€ï¸","sassy_woman":"ðŸ’â€â™€ï¸","raising_hand":"🙋","raising_hand_man":"🙋â€â™‚ï¸","raising_hand_woman":"🙋â€â™€ï¸","deaf_person":"ðŸ§","deaf_man":"ðŸ§â€â™‚ï¸","deaf_woman":"ðŸ§â€â™€ï¸","bow":"🙇","bowing_man":"🙇â€â™‚ï¸","bowing_woman":"🙇â€â™€ï¸","facepalm":"🤦","man_facepalming":"🤦â€â™‚ï¸","woman_facepalming":"🤦â€â™€ï¸","shrug":"🤷","man_shrugging":"🤷â€â™‚ï¸","woman_shrugging":"🤷â€â™€ï¸","health_worker":"🧑â€âš•ï¸","man_health_worker":"👨â€âš•ï¸","woman_health_worker":"👩â€âš•ï¸","student":"🧑â€ðŸŽ“","man_student":"👨â€ðŸŽ“","woman_student":"👩â€ðŸŽ“","teacher":"🧑â€ðŸ«","man_teacher":"👨â€ðŸ«","woman_teacher":"👩â€ðŸ«","judge":"🧑â€âš–ï¸","man_judge":"👨â€âš–ï¸","woman_judge":"👩â€âš–ï¸","farmer":"🧑â€ðŸŒ¾","man_farmer":"👨â€ðŸŒ¾","woman_farmer":"👩â€ðŸŒ¾","cook":"🧑â€ðŸ³","man_cook":"👨â€ðŸ³","woman_cook":"👩â€ðŸ³","mechanic":"🧑â€ðŸ”§","man_mechanic":"👨â€ðŸ”§","woman_mechanic":"👩â€ðŸ”§","factory_worker":"🧑â€ðŸ","man_factory_worker":"👨â€ðŸ","woman_factory_worker":"👩â€ðŸ","office_worker":"🧑â€ðŸ’¼","man_office_worker":"👨â€ðŸ’¼","woman_office_worker":"👩â€ðŸ’¼","scientist":"🧑â€ðŸ”¬","man_scientist":"👨â€ðŸ”¬","woman_scientist":"👩â€ðŸ”¬","technologist":"🧑â€ðŸ’»","man_technologist":"👨â€ðŸ’»","woman_technologist":"👩â€ðŸ’»","singer":"🧑â€ðŸŽ¤","man_singer":"👨â€ðŸŽ¤","woman_singer":"👩â€ðŸŽ¤","artist":"🧑â€ðŸŽ¨","man_artist":"👨â€ðŸŽ¨","woman_artist":"👩â€ðŸŽ¨","pilot":"🧑â€âœˆï¸","man_pilot":"👨â€âœˆï¸","woman_pilot":"👩â€âœˆï¸","astronaut":"🧑â€ðŸš€","man_astronaut":"👨â€ðŸš€","woman_astronaut":"👩â€ðŸš€","firefighter":"🧑â€ðŸš’","man_firefighter":"👨â€ðŸš’","woman_firefighter":"👩â€ðŸš’","police_officer":"👮","cop":"👮","policeman":"👮â€â™‚ï¸","policewoman":"👮â€â™€ï¸","detective":"🕵ï¸","male_detective":"🕵ï¸â€â™‚ï¸","female_detective":"🕵ï¸â€â™€ï¸","guard":"💂","guardsman":"💂â€â™‚ï¸","guardswoman":"💂â€â™€ï¸","ninja":"🥷","construction_worker":"👷","construction_worker_man":"👷â€â™‚ï¸","construction_worker_woman":"👷â€â™€ï¸","prince":"🤴","princess":"👸","person_with_turban":"👳","man_with_turban":"👳â€â™‚ï¸","woman_with_turban":"👳â€â™€ï¸","man_with_gua_pi_mao":"👲","woman_with_headscarf":"🧕","person_in_tuxedo":"🤵","man_in_tuxedo":"🤵â€â™‚ï¸","woman_in_tuxedo":"🤵â€â™€ï¸","person_with_veil":"👰","man_with_veil":"👰â€â™‚ï¸","woman_with_veil":"👰â€â™€ï¸","bride_with_veil":"👰â€â™€ï¸","pregnant_woman":"🤰","breast_feeding":"🤱","woman_feeding_baby":"👩â€ðŸ¼","man_feeding_baby":"👨â€ðŸ¼","person_feeding_baby":"🧑â€ðŸ¼","angel":"👼","santa":"🎅","mrs_claus":"🤶","mx_claus":"🧑â€ðŸŽ„","superhero":"🦸","superhero_man":"🦸â€â™‚ï¸","superhero_woman":"🦸â€â™€ï¸","supervillain":"🦹","supervillain_man":"🦹â€â™‚ï¸","supervillain_woman":"🦹â€â™€ï¸","mage":"🧙","mage_man":"🧙â€â™‚ï¸","mage_woman":"🧙â€â™€ï¸","fairy":"🧚","fairy_man":"🧚â€â™‚ï¸","fairy_woman":"🧚â€â™€ï¸","vampire":"🧛","vampire_man":"🧛â€â™‚ï¸","vampire_woman":"🧛â€â™€ï¸","merperson":"🧜","merman":"🧜â€â™‚ï¸","mermaid":"🧜â€â™€ï¸","elf":"ðŸ§","elf_man":"ðŸ§â€â™‚ï¸","elf_woman":"ðŸ§â€â™€ï¸","genie":"🧞","genie_man":"🧞â€â™‚ï¸","genie_woman":"🧞â€â™€ï¸","zombie":"🧟","zombie_man":"🧟â€â™‚ï¸","zombie_woman":"🧟â€â™€ï¸","massage":"💆","massage_man":"💆â€â™‚ï¸","massage_woman":"💆â€â™€ï¸","haircut":"💇","haircut_man":"💇â€â™‚ï¸","haircut_woman":"💇â€â™€ï¸","walking":"🚶","walking_man":"🚶â€â™‚ï¸","walking_woman":"🚶â€â™€ï¸","standing_person":"ðŸ§","standing_man":"ðŸ§â€â™‚ï¸","standing_woman":"ðŸ§â€â™€ï¸","kneeling_person":"🧎","kneeling_man":"🧎â€â™‚ï¸","kneeling_woman":"🧎â€â™€ï¸","person_with_probing_cane":"🧑â€ðŸ¦¯","man_with_probing_cane":"👨â€ðŸ¦¯","woman_with_probing_cane":"👩â€ðŸ¦¯","person_in_motorized_wheelchair":"🧑â€ðŸ¦¼","man_in_motorized_wheelchair":"👨â€ðŸ¦¼","woman_in_motorized_wheelchair":"👩â€ðŸ¦¼","person_in_manual_wheelchair":"🧑â€ðŸ¦½","man_in_manual_wheelchair":"👨â€ðŸ¦½","woman_in_manual_wheelchair":"👩â€ðŸ¦½","runner":"ðŸƒ","running":"ðŸƒ","running_man":"ðŸƒâ€â™‚ï¸","running_woman":"ðŸƒâ€â™€ï¸","woman_dancing":"💃","dancer":"💃","man_dancing":"🕺","business_suit_levitating":"🕴ï¸","dancers":"👯","dancing_men":"👯â€â™‚ï¸","dancing_women":"👯â€â™€ï¸","sauna_person":"🧖","sauna_man":"🧖â€â™‚ï¸","sauna_woman":"🧖â€â™€ï¸","climbing":"🧗","climbing_man":"🧗â€â™‚ï¸","climbing_woman":"🧗â€â™€ï¸","person_fencing":"🤺","horse_racing":"ðŸ‡","skier":"â›·ï¸","snowboarder":"ðŸ‚","golfing":"ðŸŒï¸","golfing_man":"ðŸŒï¸â€â™‚ï¸","golfing_woman":"ðŸŒï¸â€â™€ï¸","surfer":"ðŸ„","surfing_man":"ðŸ„â€â™‚ï¸","surfing_woman":"ðŸ„â€â™€ï¸","rowboat":"🚣","rowing_man":"🚣â€â™‚ï¸","rowing_woman":"🚣â€â™€ï¸","swimmer":"ðŸŠ","swimming_man":"ðŸŠâ€â™‚ï¸","swimming_woman":"ðŸŠâ€â™€ï¸","bouncing_ball_person":"⛹ï¸","bouncing_ball_man":"⛹ï¸â€â™‚ï¸","basketball_man":"⛹ï¸â€â™‚ï¸","bouncing_ball_woman":"⛹ï¸â€â™€ï¸","basketball_woman":"⛹ï¸â€â™€ï¸","weight_lifting":"ðŸ‹ï¸","weight_lifting_man":"ðŸ‹ï¸â€â™‚ï¸","weight_lifting_woman":"ðŸ‹ï¸â€â™€ï¸","bicyclist":"🚴","biking_man":"🚴â€â™‚ï¸","biking_woman":"🚴â€â™€ï¸","mountain_bicyclist":"🚵","mountain_biking_man":"🚵â€â™‚ï¸","mountain_biking_woman":"🚵â€â™€ï¸","cartwheeling":"🤸","man_cartwheeling":"🤸â€â™‚ï¸","woman_cartwheeling":"🤸â€â™€ï¸","wrestling":"🤼","men_wrestling":"🤼â€â™‚ï¸","women_wrestling":"🤼â€â™€ï¸","water_polo":"🤽","man_playing_water_polo":"🤽â€â™‚ï¸","woman_playing_water_polo":"🤽â€â™€ï¸","handball_person":"🤾","man_playing_handball":"🤾â€â™‚ï¸","woman_playing_handball":"🤾â€â™€ï¸","juggling_person":"🤹","man_juggling":"🤹â€â™‚ï¸","woman_juggling":"🤹â€â™€ï¸","lotus_position":"🧘","lotus_position_man":"🧘â€â™‚ï¸","lotus_position_woman":"🧘â€â™€ï¸","bath":"🛀","sleeping_bed":"🛌","people_holding_hands":"🧑â€ðŸ¤â€ðŸ§‘","two_women_holding_hands":"ðŸ‘","couple":"👫","two_men_holding_hands":"👬","couplekiss":"ðŸ’","couplekiss_man_woman":"👩â€â¤ï¸â€ðŸ’‹â€ðŸ‘¨","couplekiss_man_man":"👨â€â¤ï¸â€ðŸ’‹â€ðŸ‘¨","couplekiss_woman_woman":"👩â€â¤ï¸â€ðŸ’‹â€ðŸ‘©","couple_with_heart":"💑","couple_with_heart_woman_man":"👩â€â¤ï¸â€ðŸ‘¨","couple_with_heart_man_man":"👨â€â¤ï¸â€ðŸ‘¨","couple_with_heart_woman_woman":"👩â€â¤ï¸â€ðŸ‘©","family":"👪","family_man_woman_boy":"👨â€ðŸ‘©â€ðŸ‘¦","family_man_woman_girl":"👨â€ðŸ‘©â€ðŸ‘§","family_man_woman_girl_boy":"👨â€ðŸ‘©â€ðŸ‘§â€ðŸ‘¦","family_man_woman_boy_boy":"👨â€ðŸ‘©â€ðŸ‘¦â€ðŸ‘¦","family_man_woman_girl_girl":"👨â€ðŸ‘©â€ðŸ‘§â€ðŸ‘§","family_man_man_boy":"👨â€ðŸ‘¨â€ðŸ‘¦","family_man_man_girl":"👨â€ðŸ‘¨â€ðŸ‘§","family_man_man_girl_boy":"👨â€ðŸ‘¨â€ðŸ‘§â€ðŸ‘¦","family_man_man_boy_boy":"👨â€ðŸ‘¨â€ðŸ‘¦â€ðŸ‘¦","family_man_man_girl_girl":"👨â€ðŸ‘¨â€ðŸ‘§â€ðŸ‘§","family_woman_woman_boy":"👩â€ðŸ‘©â€ðŸ‘¦","family_woman_woman_girl":"👩â€ðŸ‘©â€ðŸ‘§","family_woman_woman_girl_boy":"👩â€ðŸ‘©â€ðŸ‘§â€ðŸ‘¦","family_woman_woman_boy_boy":"👩â€ðŸ‘©â€ðŸ‘¦â€ðŸ‘¦","family_woman_woman_girl_girl":"👩â€ðŸ‘©â€ðŸ‘§â€ðŸ‘§","family_man_boy":"👨â€ðŸ‘¦","family_man_boy_boy":"👨â€ðŸ‘¦â€ðŸ‘¦","family_man_girl":"👨â€ðŸ‘§","family_man_girl_boy":"👨â€ðŸ‘§â€ðŸ‘¦","family_man_girl_girl":"👨â€ðŸ‘§â€ðŸ‘§","family_woman_boy":"👩â€ðŸ‘¦","family_woman_boy_boy":"👩â€ðŸ‘¦â€ðŸ‘¦","family_woman_girl":"👩â€ðŸ‘§","family_woman_girl_boy":"👩â€ðŸ‘§â€ðŸ‘¦","family_woman_girl_girl":"👩â€ðŸ‘§â€ðŸ‘§","speaking_head":"🗣ï¸","bust_in_silhouette":"👤","busts_in_silhouette":"👥","people_hugging":"🫂","footprints":"👣","monkey_face":"ðŸµ","monkey":"ðŸ’","gorilla":"ðŸ¦","orangutan":"🦧","dog":"ðŸ¶","dog2":"ðŸ•","guide_dog":"🦮","service_dog":"ðŸ•â€ðŸ¦º","poodle":"ðŸ©","wolf":"ðŸº","fox_face":"🦊","raccoon":"ðŸ¦","cat":"ðŸ±","cat2":"ðŸˆ","black_cat":"ðŸˆâ€â¬›","lion":"ðŸ¦","tiger":"ðŸ¯","tiger2":"ðŸ…","leopard":"ðŸ†","horse":"ðŸ´","racehorse":"ðŸŽ","unicorn":"🦄","zebra":"🦓","deer":"🦌","bison":"🦬","cow":"ðŸ®","ox":"ðŸ‚","water_buffalo":"ðŸƒ","cow2":"ðŸ„","pig":"ðŸ·","pig2":"ðŸ–","boar":"ðŸ—","pig_nose":"ðŸ½","ram":"ðŸ","sheep":"ðŸ‘","goat":"ðŸ","dromedary_camel":"ðŸª","camel":"ðŸ«","llama":"🦙","giraffe":"🦒","elephant":"ðŸ˜","mammoth":"🦣","rhinoceros":"ðŸ¦","hippopotamus":"🦛","mouse":"ðŸ","mouse2":"ðŸ","rat":"ðŸ€","hamster":"ðŸ¹","rabbit":"ðŸ°","rabbit2":"ðŸ‡","chipmunk":"ðŸ¿ï¸","beaver":"🦫","hedgehog":"🦔","bat":"🦇","bear":"ðŸ»","polar_bear":"ðŸ»â€â„ï¸","koala":"ðŸ¨","panda_face":"ðŸ¼","sloth":"🦥","otter":"🦦","skunk":"🦨","kangaroo":"🦘","badger":"🦡","feet":"ðŸ¾","paw_prints":"ðŸ¾","turkey":"🦃","chicken":"ðŸ”","rooster":"ðŸ“","hatching_chick":"ðŸ£","baby_chick":"ðŸ¤","hatched_chick":"ðŸ¥","bird":"ðŸ¦","penguin":"ðŸ§","dove":"🕊ï¸","eagle":"🦅","duck":"🦆","swan":"🦢","owl":"🦉","dodo":"🦤","feather":"🪶","flamingo":"🦩","peacock":"🦚","parrot":"🦜","frog":"ðŸ¸","crocodile":"ðŸŠ","turtle":"ðŸ¢","lizard":"🦎","snake":"ðŸ","dragon_face":"ðŸ²","dragon":"ðŸ‰","sauropod":"🦕","t-rex":"🦖","whale":"ðŸ³","whale2":"ðŸ‹","dolphin":"ðŸ¬","flipper":"ðŸ¬","seal":"ðŸ¦","fish":"ðŸŸ","tropical_fish":"ðŸ ","blowfish":"ðŸ¡","shark":"🦈","octopus":"ðŸ™","shell":"ðŸš","snail":"ðŸŒ","butterfly":"🦋","bug":"ðŸ›","ant":"ðŸœ","bee":"ðŸ","honeybee":"ðŸ","beetle":"🪲","lady_beetle":"ðŸž","cricket":"🦗","cockroach":"🪳","spider":"🕷ï¸","spider_web":"🕸ï¸","scorpion":"🦂","mosquito":"🦟","fly":"🪰","worm":"🪱","microbe":"🦠","bouquet":"ðŸ’","cherry_blossom":"🌸","white_flower":"💮","rosette":"ðŸµï¸","rose":"🌹","wilted_flower":"🥀","hibiscus":"🌺","sunflower":"🌻","blossom":"🌼","tulip":"🌷","seedling":"🌱","potted_plant":"🪴","evergreen_tree":"🌲","deciduous_tree":"🌳","palm_tree":"🌴","cactus":"🌵","ear_of_rice":"🌾","herb":"🌿","shamrock":"☘ï¸","four_leaf_clover":"ðŸ€","maple_leaf":"ðŸ","fallen_leaf":"ðŸ‚","leaves":"ðŸƒ","grapes":"ðŸ‡","melon":"ðŸˆ","watermelon":"ðŸ‰","tangerine":"ðŸŠ","orange":"ðŸŠ","mandarin":"ðŸŠ","lemon":"ðŸ‹","banana":"ðŸŒ","pineapple":"ðŸ","mango":"ðŸ¥","apple":"ðŸŽ","green_apple":"ðŸ","pear":"ðŸ","peach":"ðŸ‘","cherries":"ðŸ’","strawberry":"ðŸ“","blueberries":"ðŸ«","kiwi_fruit":"ðŸ¥","tomato":"ðŸ…","olive":"🫒","coconut":"🥥","avocado":"🥑","eggplant":"ðŸ†","potato":"🥔","carrot":"🥕","corn":"🌽","hot_pepper":"🌶ï¸","bell_pepper":"🫑","cucumber":"🥒","leafy_green":"🥬","broccoli":"🥦","garlic":"🧄","onion":"🧅","mushroom":"ðŸ„","peanuts":"🥜","chestnut":"🌰","bread":"ðŸž","croissant":"ðŸ¥","baguette_bread":"🥖","flatbread":"🫓","pretzel":"🥨","bagel":"🥯","pancakes":"🥞","waffle":"🧇","cheese":"🧀","meat_on_bone":"ðŸ–","poultry_leg":"ðŸ—","cut_of_meat":"🥩","bacon":"🥓","hamburger":"ðŸ”","fries":"ðŸŸ","pizza":"ðŸ•","hotdog":"ðŸŒ","sandwich":"🥪","taco":"🌮","burrito":"🌯","tamale":"🫔","stuffed_flatbread":"🥙","falafel":"🧆","egg":"🥚","fried_egg":"ðŸ³","shallow_pan_of_food":"🥘","stew":"ðŸ²","fondue":"🫕","bowl_with_spoon":"🥣","green_salad":"🥗","popcorn":"ðŸ¿","butter":"🧈","salt":"🧂","canned_food":"🥫","bento":"ðŸ±","rice_cracker":"ðŸ˜","rice_ball":"ðŸ™","rice":"ðŸš","curry":"ðŸ›","ramen":"ðŸœ","spaghetti":"ðŸ","sweet_potato":"ðŸ ","oden":"ðŸ¢","sushi":"ðŸ£","fried_shrimp":"ðŸ¤","fish_cake":"ðŸ¥","moon_cake":"🥮","dango":"ðŸ¡","dumpling":"🥟","fortune_cookie":"🥠","takeout_box":"🥡","crab":"🦀","lobster":"🦞","shrimp":"ðŸ¦","squid":"🦑","oyster":"🦪","icecream":"ðŸ¦","shaved_ice":"ðŸ§","ice_cream":"ðŸ¨","doughnut":"ðŸ©","cookie":"ðŸª","birthday":"🎂","cake":"ðŸ°","cupcake":"ðŸ§","pie":"🥧","chocolate_bar":"ðŸ«","candy":"ðŸ¬","lollipop":"ðŸ","custard":"ðŸ®","honey_pot":"ðŸ¯","baby_bottle":"ðŸ¼","milk_glass":"🥛","coffee":"☕","teapot":"🫖","tea":"ðŸµ","sake":"ðŸ¶","champagne":"ðŸ¾","wine_glass":"ðŸ·","cocktail":"ðŸ¸","tropical_drink":"ðŸ¹","beer":"ðŸº","beers":"ðŸ»","clinking_glasses":"🥂","tumbler_glass":"🥃","cup_with_straw":"🥤","bubble_tea":"🧋","beverage_box":"🧃","mate":"🧉","ice_cube":"🧊","chopsticks":"🥢","plate_with_cutlery":"ðŸ½ï¸","fork_and_knife":"ðŸ´","spoon":"🥄","hocho":"🔪","knife":"🔪","amphora":"ðŸº","earth_africa":"ðŸŒ","earth_americas":"🌎","earth_asia":"ðŸŒ","globe_with_meridians":"ðŸŒ","world_map":"🗺ï¸","japan":"🗾","compass":"ðŸ§","mountain_snow":"ðŸ”ï¸","mountain":"â›°ï¸","volcano":"🌋","mount_fuji":"🗻","camping":"ðŸ•ï¸","beach_umbrella":"ðŸ–ï¸","desert":"ðŸœï¸","desert_island":"ðŸï¸","national_park":"ðŸžï¸","stadium":"ðŸŸï¸","classical_building":"ðŸ›ï¸","building_construction":"ðŸ—ï¸","bricks":"🧱","rock":"🪨","wood":"🪵","hut":"🛖","houses":"ðŸ˜ï¸","derelict_house":"ðŸšï¸","house":"ðŸ ","house_with_garden":"ðŸ¡","office":"ðŸ¢","post_office":"ðŸ£","european_post_office":"ðŸ¤","hospital":"ðŸ¥","bank":"ðŸ¦","hotel":"ðŸ¨","love_hotel":"ðŸ©","convenience_store":"ðŸª","school":"ðŸ«","department_store":"ðŸ¬","factory":"ðŸ","japanese_castle":"ðŸ¯","european_castle":"ðŸ°","wedding":"💒","tokyo_tower":"🗼","statue_of_liberty":"🗽","church":"⛪","mosque":"🕌","hindu_temple":"🛕","synagogue":"ðŸ•","shinto_shrine":"⛩ï¸","kaaba":"🕋","fountain":"⛲","tent":"⛺","foggy":"ðŸŒ","night_with_stars":"🌃","cityscape":"ðŸ™ï¸","sunrise_over_mountains":"🌄","sunrise":"🌅","city_sunset":"🌆","city_sunrise":"🌇","bridge_at_night":"🌉","hotsprings":"♨ï¸","carousel_horse":"🎠","ferris_wheel":"🎡","roller_coaster":"🎢","barber":"💈","circus_tent":"🎪","steam_locomotive":"🚂","railway_car":"🚃","bullettrain_side":"🚄","bullettrain_front":"🚅","train2":"🚆","metro":"🚇","light_rail":"🚈","station":"🚉","tram":"🚊","monorail":"ðŸš","mountain_railway":"🚞","train":"🚋","bus":"🚌","oncoming_bus":"ðŸš","trolleybus":"🚎","minibus":"ðŸš","ambulance":"🚑","fire_engine":"🚒","police_car":"🚓","oncoming_police_car":"🚔","taxi":"🚕","oncoming_taxi":"🚖","car":"🚗","red_car":"🚗","oncoming_automobile":"🚘","blue_car":"🚙","pickup_truck":"🛻","truck":"🚚","articulated_lorry":"🚛","tractor":"🚜","racing_car":"ðŸŽï¸","motorcycle":"ðŸï¸","motor_scooter":"🛵","manual_wheelchair":"🦽","motorized_wheelchair":"🦼","auto_rickshaw":"🛺","bike":"🚲","kick_scooter":"🛴","skateboard":"🛹","roller_skate":"🛼","busstop":"ðŸš","motorway":"🛣ï¸","railway_track":"🛤ï¸","oil_drum":"🛢ï¸","fuelpump":"⛽","rotating_light":"🚨","traffic_light":"🚥","vertical_traffic_light":"🚦","stop_sign":"🛑","construction":"🚧","anchor":"âš“","boat":"⛵","sailboat":"⛵","canoe":"🛶","speedboat":"🚤","passenger_ship":"🛳ï¸","ferry":"â›´ï¸","motor_boat":"🛥ï¸","ship":"🚢","airplane":"✈ï¸","small_airplane":"🛩ï¸","flight_departure":"🛫","flight_arrival":"🛬","parachute":"🪂","seat":"💺","helicopter":"ðŸš","suspension_railway":"🚟","mountain_cableway":"🚠","aerial_tramway":"🚡","artificial_satellite":"🛰ï¸","rocket":"🚀","flying_saucer":"🛸","bellhop_bell":"🛎ï¸","luggage":"🧳","hourglass":"⌛","hourglass_flowing_sand":"â³","watch":"⌚","alarm_clock":"â°","stopwatch":"â±ï¸","timer_clock":"â²ï¸","mantelpiece_clock":"🕰ï¸","clock12":"🕛","clock1230":"🕧","clock1":"ðŸ•","clock130":"🕜","clock2":"🕑","clock230":"ðŸ•","clock3":"🕒","clock330":"🕞","clock4":"🕓","clock430":"🕟","clock5":"🕔","clock530":"🕠","clock6":"🕕","clock630":"🕡","clock7":"🕖","clock730":"🕢","clock8":"🕗","clock830":"🕣","clock9":"🕘","clock930":"🕤","clock10":"🕙","clock1030":"🕥","clock11":"🕚","clock1130":"🕦","new_moon":"🌑","waxing_crescent_moon":"🌒","first_quarter_moon":"🌓","moon":"🌔","waxing_gibbous_moon":"🌔","full_moon":"🌕","waning_gibbous_moon":"🌖","last_quarter_moon":"🌗","waning_crescent_moon":"🌘","crescent_moon":"🌙","new_moon_with_face":"🌚","first_quarter_moon_with_face":"🌛","last_quarter_moon_with_face":"🌜","thermometer":"🌡ï¸","sunny":"☀ï¸","full_moon_with_face":"ðŸŒ","sun_with_face":"🌞","ringed_planet":"ðŸª","star":"â","star2":"🌟","stars":"🌠","milky_way":"🌌","cloud":"â˜ï¸","partly_sunny":"â›…","cloud_with_lightning_and_rain":"⛈ï¸","sun_behind_small_cloud":"🌤ï¸","sun_behind_large_cloud":"🌥ï¸","sun_behind_rain_cloud":"🌦ï¸","cloud_with_rain":"🌧ï¸","cloud_with_snow":"🌨ï¸","cloud_with_lightning":"🌩ï¸","tornado":"🌪ï¸","fog":"🌫ï¸","wind_face":"🌬ï¸","cyclone":"🌀","rainbow":"🌈","closed_umbrella":"🌂","open_umbrella":"☂ï¸","umbrella":"☔","parasol_on_ground":"â›±ï¸","zap":"âš¡","snowflake":"â„ï¸","snowman_with_snow":"☃ï¸","snowman":"⛄","comet":"☄ï¸","fire":"🔥","droplet":"💧","ocean":"🌊","jack_o_lantern":"🎃","christmas_tree":"🎄","fireworks":"🎆","sparkler":"🎇","firecracker":"🧨","sparkles":"✨","balloon":"🎈","tada":"🎉","confetti_ball":"🎊","tanabata_tree":"🎋","bamboo":"ðŸŽ","dolls":"🎎","flags":"ðŸŽ","wind_chime":"ðŸŽ","rice_scene":"🎑","red_envelope":"🧧","ribbon":"🎀","gift":"ðŸŽ","reminder_ribbon":"🎗ï¸","tickets":"🎟ï¸","ticket":"🎫","medal_military":"🎖ï¸","trophy":"ðŸ†","medal_sports":"ðŸ…","1st_place_medal":"🥇","2nd_place_medal":"🥈","3rd_place_medal":"🥉","soccer":"âš½","baseball":"âš¾","softball":"🥎","basketball":"ðŸ€","volleyball":"ðŸ","football":"ðŸˆ","rugby_football":"ðŸ‰","tennis":"🎾","flying_disc":"ðŸ¥","bowling":"🎳","cricket_game":"ðŸ","field_hockey":"ðŸ‘","ice_hockey":"ðŸ’","lacrosse":"ðŸ¥","ping_pong":"ðŸ“","badminton":"ðŸ¸","boxing_glove":"🥊","martial_arts_uniform":"🥋","goal_net":"🥅","golf":"⛳","ice_skate":"⛸ï¸","fishing_pole_and_fish":"🎣","diving_mask":"🤿","running_shirt_with_sash":"🎽","ski":"🎿","sled":"🛷","curling_stone":"🥌","dart":"🎯","yo_yo":"🪀","kite":"ðŸª","8ball":"🎱","crystal_ball":"🔮","magic_wand":"🪄","nazar_amulet":"🧿","video_game":"🎮","joystick":"🕹ï¸","slot_machine":"🎰","game_die":"🎲","jigsaw":"🧩","teddy_bear":"🧸","pinata":"🪅","nesting_dolls":"🪆","spades":"â™ ï¸","hearts":"♥ï¸","diamonds":"♦ï¸","clubs":"♣ï¸","chess_pawn":"♟ï¸","black_joker":"ðŸƒ","mahjong":"🀄","flower_playing_cards":"🎴","performing_arts":"ðŸŽ","framed_picture":"🖼ï¸","art":"🎨","thread":"🧵","sewing_needle":"🪡","yarn":"🧶","knot":"🪢","eyeglasses":"👓","dark_sunglasses":"🕶ï¸","goggles":"🥽","lab_coat":"🥼","safety_vest":"🦺","necktie":"👔","shirt":"👕","tshirt":"👕","jeans":"👖","scarf":"🧣","gloves":"🧤","coat":"🧥","socks":"🧦","dress":"👗","kimono":"👘","sari":"🥻","one_piece_swimsuit":"🩱","swim_brief":"🩲","shorts":"🩳","bikini":"👙","womans_clothes":"👚","purse":"👛","handbag":"👜","pouch":"ðŸ‘","shopping":"ðŸ›ï¸","school_satchel":"🎒","thong_sandal":"🩴","mans_shoe":"👞","shoe":"👞","athletic_shoe":"👟","hiking_boot":"🥾","flat_shoe":"🥿","high_heel":"👠","sandal":"👡","ballet_shoes":"🩰","boot":"👢","crown":"👑","womans_hat":"👒","tophat":"🎩","mortar_board":"🎓","billed_cap":"🧢","military_helmet":"🪖","rescue_worker_helmet":"⛑ï¸","prayer_beads":"📿","lipstick":"💄","ring":"ðŸ’","gem":"💎","mute":"🔇","speaker":"🔈","sound":"🔉","loud_sound":"🔊","loudspeaker":"📢","mega":"📣","postal_horn":"📯","bell":"🔔","no_bell":"🔕","musical_score":"🎼","musical_note":"🎵","notes":"🎶","studio_microphone":"🎙ï¸","level_slider":"🎚ï¸","control_knobs":"🎛ï¸","microphone":"🎤","headphones":"🎧","radio":"📻","saxophone":"🎷","accordion":"🪗","guitar":"🎸","musical_keyboard":"🎹","trumpet":"🎺","violin":"🎻","banjo":"🪕","drum":"ðŸ¥","long_drum":"🪘","iphone":"📱","calling":"📲","phone":"☎ï¸","telephone":"☎ï¸","telephone_receiver":"📞","pager":"📟","fax":"📠","battery":"🔋","electric_plug":"🔌","computer":"💻","desktop_computer":"🖥ï¸","printer":"🖨ï¸","keyboard":"⌨ï¸","computer_mouse":"🖱ï¸","trackball":"🖲ï¸","minidisc":"💽","floppy_disk":"💾","cd":"💿","dvd":"📀","abacus":"🧮","movie_camera":"🎥","film_strip":"🎞ï¸","film_projector":"📽ï¸","clapper":"🎬","tv":"📺","camera":"📷","camera_flash":"📸","video_camera":"📹","vhs":"📼","mag":"ðŸ”","mag_right":"🔎","candle":"🕯ï¸","bulb":"💡","flashlight":"🔦","izakaya_lantern":"ðŸ®","lantern":"ðŸ®","diya_lamp":"🪔","notebook_with_decorative_cover":"📔","closed_book":"📕","book":"📖","open_book":"📖","green_book":"📗","blue_book":"📘","orange_book":"📙","books":"📚","notebook":"📓","ledger":"📒","page_with_curl":"📃","scroll":"📜","page_facing_up":"📄","newspaper":"📰","newspaper_roll":"🗞ï¸","bookmark_tabs":"📑","bookmark":"🔖","label":"ðŸ·ï¸","moneybag":"💰","coin":"🪙","yen":"💴","dollar":"💵","euro":"💶","pound":"💷","money_with_wings":"💸","credit_card":"💳","receipt":"🧾","chart":"💹","envelope":"✉ï¸","email":"📧","e-mail":"📧","incoming_envelope":"📨","envelope_with_arrow":"📩","outbox_tray":"📤","inbox_tray":"📥","package":"📦","mailbox":"📫","mailbox_closed":"📪","mailbox_with_mail":"📬","mailbox_with_no_mail":"ðŸ“","postbox":"📮","ballot_box":"🗳ï¸","pencil2":"âœï¸","black_nib":"✒ï¸","fountain_pen":"🖋ï¸","pen":"🖊ï¸","paintbrush":"🖌ï¸","crayon":"ðŸ–ï¸","memo":"ðŸ“","pencil":"ðŸ“","briefcase":"💼","file_folder":"ðŸ“","open_file_folder":"📂","card_index_dividers":"🗂ï¸","date":"📅","calendar":"📆","spiral_notepad":"🗒ï¸","spiral_calendar":"🗓ï¸","card_index":"📇","chart_with_upwards_trend":"📈","chart_with_downwards_trend":"📉","bar_chart":"📊","clipboard":"📋","pushpin":"📌","round_pushpin":"ðŸ“","paperclip":"📎","paperclips":"🖇ï¸","straight_ruler":"ðŸ“","triangular_ruler":"ðŸ“","scissors":"✂ï¸","card_file_box":"🗃ï¸","file_cabinet":"🗄ï¸","wastebasket":"🗑ï¸","lock":"🔒","unlock":"🔓","lock_with_ink_pen":"ðŸ”","closed_lock_with_key":"ðŸ”","key":"🔑","old_key":"ðŸ—ï¸","hammer":"🔨","axe":"🪓","pick":"â›ï¸","hammer_and_pick":"âš’ï¸","hammer_and_wrench":"🛠ï¸","dagger":"🗡ï¸","crossed_swords":"âš”ï¸","gun":"🔫","boomerang":"🪃","bow_and_arrow":"ðŸ¹","shield":"🛡ï¸","carpentry_saw":"🪚","wrench":"🔧","screwdriver":"🪛","nut_and_bolt":"🔩","gear":"âš™ï¸","clamp":"🗜ï¸","balance_scale":"âš–ï¸","probing_cane":"🦯","link":"🔗","chains":"⛓ï¸","hook":"ðŸª","toolbox":"🧰","magnet":"🧲","ladder":"🪜","alembic":"âš—ï¸","test_tube":"🧪","petri_dish":"🧫","dna":"🧬","microscope":"🔬","telescope":"ðŸ”","satellite":"📡","syringe":"💉","drop_of_blood":"🩸","pill":"💊","adhesive_bandage":"🩹","stethoscope":"🩺","door":"🚪","elevator":"🛗","mirror":"🪞","window":"🪟","bed":"ðŸ›ï¸","couch_and_lamp":"🛋ï¸","chair":"🪑","toilet":"🚽","plunger":"🪠","shower":"🚿","bathtub":"ðŸ›","mouse_trap":"🪤","razor":"🪒","lotion_bottle":"🧴","safety_pin":"🧷","broom":"🧹","basket":"🧺","roll_of_paper":"🧻","bucket":"🪣","soap":"🧼","toothbrush":"🪥","sponge":"🧽","fire_extinguisher":"🧯","shopping_cart":"🛒","smoking":"🚬","coffin":"âš°ï¸","headstone":"🪦","funeral_urn":"âš±ï¸","moyai":"🗿","placard":"🪧","atm":"ðŸ§","put_litter_in_its_place":"🚮","potable_water":"🚰","wheelchair":"♿","mens":"🚹","womens":"🚺","restroom":"🚻","baby_symbol":"🚼","wc":"🚾","passport_control":"🛂","customs":"🛃","baggage_claim":"🛄","left_luggage":"🛅","warning":"âš ï¸","children_crossing":"🚸","no_entry":"â›”","no_entry_sign":"🚫","no_bicycles":"🚳","no_smoking":"ðŸš","do_not_litter":"🚯","non-potable_water":"🚱","no_pedestrians":"🚷","no_mobile_phones":"📵","underage":"🔞","radioactive":"☢ï¸","biohazard":"☣ï¸","arrow_up":"⬆ï¸","arrow_upper_right":"↗ï¸","arrow_right":"âž¡ï¸","arrow_lower_right":"↘ï¸","arrow_down":"⬇ï¸","arrow_lower_left":"↙ï¸","arrow_left":"⬅ï¸","arrow_upper_left":"↖ï¸","arrow_up_down":"↕ï¸","left_right_arrow":"↔ï¸","leftwards_arrow_with_hook":"↩ï¸","arrow_right_hook":"↪ï¸","arrow_heading_up":"⤴ï¸","arrow_heading_down":"⤵ï¸","arrows_clockwise":"🔃","arrows_counterclockwise":"🔄","back":"🔙","end":"🔚","on":"🔛","soon":"🔜","top":"ðŸ”","place_of_worship":"ðŸ›","atom_symbol":"âš›ï¸","om":"🕉ï¸","star_of_david":"✡ï¸","wheel_of_dharma":"☸ï¸","yin_yang":"☯ï¸","latin_cross":"âœï¸","orthodox_cross":"☦ï¸","star_and_crescent":"☪ï¸","peace_symbol":"☮ï¸","menorah":"🕎","six_pointed_star":"🔯","aries":"♈","taurus":"♉","gemini":"♊","cancer":"♋","leo":"♌","virgo":"â™","libra":"♎","scorpius":"â™","sagittarius":"â™","capricorn":"♑","aquarius":"â™’","pisces":"♓","ophiuchus":"⛎","twisted_rightwards_arrows":"🔀","repeat":"ðŸ”","repeat_one":"🔂","arrow_forward":"â–¶ï¸","fast_forward":"â©","next_track_button":"âï¸","play_or_pause_button":"â¯ï¸","arrow_backward":"â—€ï¸","rewind":"âª","previous_track_button":"â®ï¸","arrow_up_small":"🔼","arrow_double_up":"â«","arrow_down_small":"🔽","arrow_double_down":"â¬","pause_button":"â¸ï¸","stop_button":"â¹ï¸","record_button":"âºï¸","eject_button":"âï¸","cinema":"🎦","low_brightness":"🔅","high_brightness":"🔆","signal_strength":"📶","vibration_mode":"📳","mobile_phone_off":"📴","female_sign":"♀ï¸","male_sign":"♂ï¸","transgender_symbol":"âš§ï¸","heavy_multiplication_x":"✖ï¸","heavy_plus_sign":"âž•","heavy_minus_sign":"âž–","heavy_division_sign":"âž—","infinity":"♾ï¸","bangbang":"‼ï¸","interrobang":"â‰ï¸","question":"â“","grey_question":"â”","grey_exclamation":"â•","exclamation":"â—","heavy_exclamation_mark":"â—","wavy_dash":"〰ï¸","currency_exchange":"💱","heavy_dollar_sign":"💲","medical_symbol":"âš•ï¸","recycle":"â™»ï¸","fleur_de_lis":"âšœï¸","trident":"🔱","name_badge":"📛","beginner":"🔰","o":"â•","white_check_mark":"✅","ballot_box_with_check":"☑ï¸","heavy_check_mark":"✔ï¸","x":"âŒ","negative_squared_cross_mark":"âŽ","curly_loop":"âž°","loop":"âž¿","part_alternation_mark":"〽ï¸","eight_spoked_asterisk":"✳ï¸","eight_pointed_black_star":"✴ï¸","sparkle":"â‡ï¸","copyright":"©ï¸","registered":"®ï¸","tm":"â„¢ï¸","hash":"#ï¸âƒ£","asterisk":"*ï¸âƒ£","zero":"0ï¸âƒ£","one":"1ï¸âƒ£","two":"2ï¸âƒ£","three":"3ï¸âƒ£","four":"4ï¸âƒ£","five":"5ï¸âƒ£","six":"6ï¸âƒ£","seven":"7ï¸âƒ£","eight":"8ï¸âƒ£","nine":"9ï¸âƒ£","keycap_ten":"🔟","capital_abcd":"🔠","abcd":"🔡","symbols":"🔣","abc":"🔤","a":"🅰ï¸","ab":"🆎","b":"🅱ï¸","cl":"🆑","cool":"🆒","free":"🆓","information_source":"ℹï¸","id":"🆔","m":"â“‚ï¸","new":"🆕","ng":"🆖","o2":"🅾ï¸","ok":"🆗","parking":"🅿ï¸","sos":"🆘","up":"🆙","vs":"🆚","koko":"ðŸˆ","sa":"🈂ï¸","u6708":"🈷ï¸","u6709":"🈶","u6307":"🈯","ideograph_advantage":"ðŸ‰","u5272":"🈹","u7121":"🈚","u7981":"🈲","accept":"🉑","u7533":"🈸","u5408":"🈴","u7a7a":"🈳","congratulations":"㊗ï¸","secret":"㊙ï¸","u55b6":"🈺","u6e80":"🈵","red_circle":"🔴","orange_circle":"🟠","yellow_circle":"🟡","green_circle":"🟢","large_blue_circle":"🔵","purple_circle":"🟣","brown_circle":"🟤","black_circle":"âš«","white_circle":"⚪","red_square":"🟥","orange_square":"🟧","yellow_square":"🟨","green_square":"🟩","blue_square":"🟦","purple_square":"🟪","brown_square":"🟫","black_large_square":"⬛","white_large_square":"⬜","black_medium_square":"â—¼ï¸","white_medium_square":"â—»ï¸","black_medium_small_square":"â—¾","white_medium_small_square":"â—½","black_small_square":"â–ªï¸","white_small_square":"â–«ï¸","large_orange_diamond":"🔶","large_blue_diamond":"🔷","small_orange_diamond":"🔸","small_blue_diamond":"🔹","small_red_triangle":"🔺","small_red_triangle_down":"🔻","diamond_shape_with_a_dot_inside":"💠","radio_button":"🔘","white_square_button":"🔳","black_square_button":"🔲","checkered_flag":"ðŸ","triangular_flag_on_post":"🚩","crossed_flags":"🎌","black_flag":"ðŸ´","white_flag":"ðŸ³ï¸","rainbow_flag":"ðŸ³ï¸â€ðŸŒˆ","transgender_flag":"ðŸ³ï¸â€âš§ï¸","pirate_flag":"ðŸ´â€â˜ ï¸","ascension_island":"🇦🇨","andorra":"🇦🇩","united_arab_emirates":"🇦🇪","afghanistan":"🇦🇫","antigua_barbuda":"🇦🇬","anguilla":"🇦🇮","albania":"🇦🇱","armenia":"🇦🇲","angola":"🇦🇴","antarctica":"🇦🇶","argentina":"🇦🇷","american_samoa":"🇦🇸","austria":"🇦🇹","australia":"🇦🇺","aruba":"🇦🇼","aland_islands":"🇦🇽","azerbaijan":"🇦🇿","bosnia_herzegovina":"🇧🇦","barbados":"🇧🇧","bangladesh":"🇧🇩","belgium":"🇧🇪","burkina_faso":"🇧🇫","bulgaria":"🇧🇬","bahrain":"🇧ðŸ‡","burundi":"🇧🇮","benin":"🇧🇯","st_barthelemy":"🇧🇱","bermuda":"🇧🇲","brunei":"🇧🇳","bolivia":"🇧🇴","caribbean_netherlands":"🇧🇶","brazil":"🇧🇷","bahamas":"🇧🇸","bhutan":"🇧🇹","bouvet_island":"🇧🇻","botswana":"🇧🇼","belarus":"🇧🇾","belize":"🇧🇿","canada":"🇨🇦","cocos_islands":"🇨🇨","congo_kinshasa":"🇨🇩","central_african_republic":"🇨🇫","congo_brazzaville":"🇨🇬","switzerland":"🇨ðŸ‡","cote_divoire":"🇨🇮","cook_islands":"🇨🇰","chile":"🇨🇱","cameroon":"🇨🇲","cn":"🇨🇳","colombia":"🇨🇴","clipperton_island":"🇨🇵","costa_rica":"🇨🇷","cuba":"🇨🇺","cape_verde":"🇨🇻","curacao":"🇨🇼","christmas_island":"🇨🇽","cyprus":"🇨🇾","czech_republic":"🇨🇿","de":"🇩🇪","diego_garcia":"🇩🇬","djibouti":"🇩🇯","denmark":"🇩🇰","dominica":"🇩🇲","dominican_republic":"🇩🇴","algeria":"🇩🇿","ceuta_melilla":"🇪🇦","ecuador":"🇪🇨","estonia":"🇪🇪","egypt":"🇪🇬","western_sahara":"🇪ðŸ‡","eritrea":"🇪🇷","es":"🇪🇸","ethiopia":"🇪🇹","eu":"🇪🇺","european_union":"🇪🇺","finland":"🇫🇮","fiji":"🇫🇯","falkland_islands":"🇫🇰","micronesia":"🇫🇲","faroe_islands":"🇫🇴","fr":"🇫🇷","gabon":"🇬🇦","gb":"🇬🇧","uk":"🇬🇧","grenada":"🇬🇩","georgia":"🇬🇪","french_guiana":"🇬🇫","guernsey":"🇬🇬","ghana":"🇬ðŸ‡","gibraltar":"🇬🇮","greenland":"🇬🇱","gambia":"🇬🇲","guinea":"🇬🇳","guadeloupe":"🇬🇵","equatorial_guinea":"🇬🇶","greece":"🇬🇷","south_georgia_south_sandwich_islands":"🇬🇸","guatemala":"🇬🇹","guam":"🇬🇺","guinea_bissau":"🇬🇼","guyana":"🇬🇾","hong_kong":"ðŸ‡ðŸ‡°","heard_mcdonald_islands":"ðŸ‡ðŸ‡²","honduras":"ðŸ‡ðŸ‡³","croatia":"ðŸ‡ðŸ‡·","haiti":"ðŸ‡ðŸ‡¹","hungary":"ðŸ‡ðŸ‡º","canary_islands":"🇮🇨","indonesia":"🇮🇩","ireland":"🇮🇪","israel":"🇮🇱","isle_of_man":"🇮🇲","india":"🇮🇳","british_indian_ocean_territory":"🇮🇴","iraq":"🇮🇶","iran":"🇮🇷","iceland":"🇮🇸","it":"🇮🇹","jersey":"🇯🇪","jamaica":"🇯🇲","jordan":"🇯🇴","jp":"🇯🇵","kenya":"🇰🇪","kyrgyzstan":"🇰🇬","cambodia":"🇰ðŸ‡","kiribati":"🇰🇮","comoros":"🇰🇲","st_kitts_nevis":"🇰🇳","north_korea":"🇰🇵","kr":"🇰🇷","kuwait":"🇰🇼","cayman_islands":"🇰🇾","kazakhstan":"🇰🇿","laos":"🇱🇦","lebanon":"🇱🇧","st_lucia":"🇱🇨","liechtenstein":"🇱🇮","sri_lanka":"🇱🇰","liberia":"🇱🇷","lesotho":"🇱🇸","lithuania":"🇱🇹","luxembourg":"🇱🇺","latvia":"🇱🇻","libya":"🇱🇾","morocco":"🇲🇦","monaco":"🇲🇨","moldova":"🇲🇩","montenegro":"🇲🇪","st_martin":"🇲🇫","madagascar":"🇲🇬","marshall_islands":"🇲ðŸ‡","macedonia":"🇲🇰","mali":"🇲🇱","myanmar":"🇲🇲","mongolia":"🇲🇳","macau":"🇲🇴","northern_mariana_islands":"🇲🇵","martinique":"🇲🇶","mauritania":"🇲🇷","montserrat":"🇲🇸","malta":"🇲🇹","mauritius":"🇲🇺","maldives":"🇲🇻","malawi":"🇲🇼","mexico":"🇲🇽","malaysia":"🇲🇾","mozambique":"🇲🇿","namibia":"🇳🇦","new_caledonia":"🇳🇨","niger":"🇳🇪","norfolk_island":"🇳🇫","nigeria":"🇳🇬","nicaragua":"🇳🇮","netherlands":"🇳🇱","norway":"🇳🇴","nepal":"🇳🇵","nauru":"🇳🇷","niue":"🇳🇺","new_zealand":"🇳🇿","oman":"🇴🇲","panama":"🇵🇦","peru":"🇵🇪","french_polynesia":"🇵🇫","papua_new_guinea":"🇵🇬","philippines":"🇵ðŸ‡","pakistan":"🇵🇰","poland":"🇵🇱","st_pierre_miquelon":"🇵🇲","pitcairn_islands":"🇵🇳","puerto_rico":"🇵🇷","palestinian_territories":"🇵🇸","portugal":"🇵🇹","palau":"🇵🇼","paraguay":"🇵🇾","qatar":"🇶🇦","reunion":"🇷🇪","romania":"🇷🇴","serbia":"🇷🇸","ru":"🇷🇺","rwanda":"🇷🇼","saudi_arabia":"🇸🇦","solomon_islands":"🇸🇧","seychelles":"🇸🇨","sudan":"🇸🇩","sweden":"🇸🇪","singapore":"🇸🇬","st_helena":"🇸ðŸ‡","slovenia":"🇸🇮","svalbard_jan_mayen":"🇸🇯","slovakia":"🇸🇰","sierra_leone":"🇸🇱","san_marino":"🇸🇲","senegal":"🇸🇳","somalia":"🇸🇴","suriname":"🇸🇷","south_sudan":"🇸🇸","sao_tome_principe":"🇸🇹","el_salvador":"🇸🇻","sint_maarten":"🇸🇽","syria":"🇸🇾","swaziland":"🇸🇿","tristan_da_cunha":"🇹🇦","turks_caicos_islands":"🇹🇨","chad":"🇹🇩","french_southern_territories":"🇹🇫","togo":"🇹🇬","thailand":"🇹ðŸ‡","tajikistan":"🇹🇯","tokelau":"🇹🇰","timor_leste":"🇹🇱","turkmenistan":"🇹🇲","tunisia":"🇹🇳","tonga":"🇹🇴","tr":"🇹🇷","trinidad_tobago":"🇹🇹","tuvalu":"🇹🇻","taiwan":"🇹🇼","tanzania":"🇹🇿","ukraine":"🇺🇦","uganda":"🇺🇬","us_outlying_islands":"🇺🇲","united_nations":"🇺🇳","us":"🇺🇸","uruguay":"🇺🇾","uzbekistan":"🇺🇿","vatican_city":"🇻🇦","st_vincent_grenadines":"🇻🇨","venezuela":"🇻🇪","british_virgin_islands":"🇻🇬","us_virgin_islands":"🇻🇮","vietnam":"🇻🇳","vanuatu":"🇻🇺","wallis_futuna":"🇼🇫","samoa":"🇼🇸","kosovo":"🇽🇰","yemen":"🇾🇪","mayotte":"🇾🇹","south_africa":"🇿🇦","zambia":"🇿🇲","zimbabwe":"🇿🇼","england":"ðŸ´ó §ó ¢ó ¥ó ®ó §ó ¿","scotland":"ðŸ´ó §ó ¢ó ³ó £ó ´ó ¿","wales":"ðŸ´ó §ó ¢ó ·ó ¬ó ³ó ¿"} \ No newline at end of file +export const emojiDictionary = { + "100": "💯", + "1234": "🔢", + grinning: "😀", + smiley: "😃", + smile: "😄", + grin: "ðŸ˜", + laughing: "😆", + satisfied: "😆", + sweat_smile: "😅", + rofl: "🤣", + joy: "😂", + slightly_smiling_face: "🙂", + upside_down_face: "🙃", + wink: "😉", + blush: "😊", + innocent: "😇", + smiling_face_with_three_hearts: "🥰", + heart_eyes: "ðŸ˜", + star_struck: "🤩", + kissing_heart: "😘", + kissing: "😗", + relaxed: "☺ï¸", + kissing_closed_eyes: "😚", + kissing_smiling_eyes: "😙", + smiling_face_with_tear: "🥲", + yum: "😋", + stuck_out_tongue: "😛", + stuck_out_tongue_winking_eye: "😜", + zany_face: "🤪", + stuck_out_tongue_closed_eyes: "ðŸ˜", + money_mouth_face: "🤑", + hugs: "🤗", + hand_over_mouth: "ðŸ¤", + shushing_face: "🤫", + thinking: "🤔", + zipper_mouth_face: "ðŸ¤", + raised_eyebrow: "🤨", + neutral_face: "ðŸ˜", + expressionless: "😑", + no_mouth: "😶", + smirk: "ðŸ˜", + unamused: "😒", + roll_eyes: "🙄", + grimacing: "😬", + lying_face: "🤥", + relieved: "😌", + pensive: "😔", + sleepy: "😪", + drooling_face: "🤤", + sleeping: "😴", + mask: "😷", + face_with_thermometer: "🤒", + face_with_head_bandage: "🤕", + nauseated_face: "🤢", + vomiting_face: "🤮", + sneezing_face: "🤧", + hot_face: "🥵", + cold_face: "🥶", + woozy_face: "🥴", + dizzy_face: "😵", + exploding_head: "🤯", + cowboy_hat_face: "🤠", + partying_face: "🥳", + disguised_face: "🥸", + sunglasses: "😎", + nerd_face: "🤓", + monocle_face: "ðŸ§", + confused: "😕", + worried: "😟", + slightly_frowning_face: "ðŸ™", + frowning_face: "☹ï¸", + open_mouth: "😮", + hushed: "😯", + astonished: "😲", + flushed: "😳", + pleading_face: "🥺", + frowning: "😦", + anguished: "😧", + fearful: "😨", + cold_sweat: "😰", + disappointed_relieved: "😥", + cry: "😢", + sob: "ðŸ˜", + scream: "😱", + confounded: "😖", + persevere: "😣", + disappointed: "😞", + sweat: "😓", + weary: "😩", + tired_face: "😫", + yawning_face: "🥱", + triumph: "😤", + rage: "😡", + pout: "😡", + angry: "😠", + cursing_face: "🤬", + smiling_imp: "😈", + imp: "👿", + skull: "💀", + skull_and_crossbones: "☠ï¸", + hankey: "💩", + poop: "💩", + shit: "💩", + clown_face: "🤡", + japanese_ogre: "👹", + japanese_goblin: "👺", + ghost: "👻", + alien: "👽", + space_invader: "👾", + robot: "🤖", + smiley_cat: "😺", + smile_cat: "😸", + joy_cat: "😹", + heart_eyes_cat: "😻", + smirk_cat: "😼", + kissing_cat: "😽", + scream_cat: "🙀", + crying_cat_face: "😿", + pouting_cat: "😾", + see_no_evil: "🙈", + hear_no_evil: "🙉", + speak_no_evil: "🙊", + kiss: "💋", + love_letter: "💌", + cupid: "💘", + gift_heart: "ðŸ’", + sparkling_heart: "💖", + heartpulse: "💗", + heartbeat: "💓", + revolving_hearts: "💞", + two_hearts: "💕", + heart_decoration: "💟", + heavy_heart_exclamation: "â£ï¸", + broken_heart: "💔", + heart: "â¤ï¸", + orange_heart: "🧡", + yellow_heart: "💛", + green_heart: "💚", + blue_heart: "💙", + purple_heart: "💜", + brown_heart: "🤎", + black_heart: "🖤", + white_heart: "ðŸ¤", + anger: "💢", + boom: "💥", + collision: "💥", + dizzy: "💫", + sweat_drops: "💦", + dash: "💨", + hole: "🕳ï¸", + bomb: "💣", + speech_balloon: "💬", + eye_speech_bubble: "ðŸ‘ï¸â€ðŸ—¨ï¸", + left_speech_bubble: "🗨ï¸", + right_anger_bubble: "🗯ï¸", + thought_balloon: "ðŸ’", + zzz: "💤", + wave: "👋", + raised_back_of_hand: "🤚", + raised_hand_with_fingers_splayed: "ðŸ–ï¸", + hand: "✋", + raised_hand: "✋", + vulcan_salute: "🖖", + ok_hand: "👌", + pinched_fingers: "🤌", + pinching_hand: "ðŸ¤", + v: "✌ï¸", + crossed_fingers: "🤞", + love_you_gesture: "🤟", + metal: "🤘", + call_me_hand: "🤙", + point_left: "👈", + point_right: "👉", + point_up_2: "👆", + middle_finger: "🖕", + fu: "🖕", + point_down: "👇", + point_up: "â˜ï¸", + "+1": "ðŸ‘", + thumbsup: "ðŸ‘", + "-1": "👎", + thumbsdown: "👎", + fist_raised: "✊", + fist: "✊", + fist_oncoming: "👊", + facepunch: "👊", + punch: "👊", + fist_left: "🤛", + fist_right: "🤜", + clap: "ðŸ‘", + raised_hands: "🙌", + open_hands: "ðŸ‘", + palms_up_together: "🤲", + handshake: "ðŸ¤", + pray: "ðŸ™", + writing_hand: "âœï¸", + nail_care: "💅", + selfie: "🤳", + muscle: "💪", + mechanical_arm: "🦾", + mechanical_leg: "🦿", + leg: "🦵", + foot: "🦶", + ear: "👂", + ear_with_hearing_aid: "🦻", + nose: "👃", + brain: "🧠", + anatomical_heart: "🫀", + lungs: "ðŸ«", + tooth: "🦷", + bone: "🦴", + eyes: "👀", + eye: "ðŸ‘ï¸", + tongue: "👅", + lips: "👄", + baby: "👶", + child: "🧒", + boy: "👦", + girl: "👧", + adult: "🧑", + blond_haired_person: "👱", + man: "👨", + bearded_person: "🧔", + red_haired_man: "👨â€ðŸ¦°", + curly_haired_man: "👨â€ðŸ¦±", + white_haired_man: "👨â€ðŸ¦³", + bald_man: "👨â€ðŸ¦²", + woman: "👩", + red_haired_woman: "👩â€ðŸ¦°", + person_red_hair: "🧑â€ðŸ¦°", + curly_haired_woman: "👩â€ðŸ¦±", + person_curly_hair: "🧑â€ðŸ¦±", + white_haired_woman: "👩â€ðŸ¦³", + person_white_hair: "🧑â€ðŸ¦³", + bald_woman: "👩â€ðŸ¦²", + person_bald: "🧑â€ðŸ¦²", + blond_haired_woman: "👱â€â™€ï¸", + blonde_woman: "👱â€â™€ï¸", + blond_haired_man: "👱â€â™‚ï¸", + older_adult: "🧓", + older_man: "👴", + older_woman: "👵", + frowning_person: "ðŸ™", + frowning_man: "ðŸ™â€â™‚ï¸", + frowning_woman: "ðŸ™â€â™€ï¸", + pouting_face: "🙎", + pouting_man: "🙎â€â™‚ï¸", + pouting_woman: "🙎â€â™€ï¸", + no_good: "🙅", + no_good_man: "🙅â€â™‚ï¸", + ng_man: "🙅â€â™‚ï¸", + no_good_woman: "🙅â€â™€ï¸", + ng_woman: "🙅â€â™€ï¸", + ok_person: "🙆", + ok_man: "🙆â€â™‚ï¸", + ok_woman: "🙆â€â™€ï¸", + tipping_hand_person: "ðŸ’", + information_desk_person: "ðŸ’", + tipping_hand_man: "ðŸ’â€â™‚ï¸", + sassy_man: "ðŸ’â€â™‚ï¸", + tipping_hand_woman: "ðŸ’â€â™€ï¸", + sassy_woman: "ðŸ’â€â™€ï¸", + raising_hand: "🙋", + raising_hand_man: "🙋â€â™‚ï¸", + raising_hand_woman: "🙋â€â™€ï¸", + deaf_person: "ðŸ§", + deaf_man: "ðŸ§â€â™‚ï¸", + deaf_woman: "ðŸ§â€â™€ï¸", + bow: "🙇", + bowing_man: "🙇â€â™‚ï¸", + bowing_woman: "🙇â€â™€ï¸", + facepalm: "🤦", + man_facepalming: "🤦â€â™‚ï¸", + woman_facepalming: "🤦â€â™€ï¸", + shrug: "🤷", + man_shrugging: "🤷â€â™‚ï¸", + woman_shrugging: "🤷â€â™€ï¸", + health_worker: "🧑â€âš•ï¸", + man_health_worker: "👨â€âš•ï¸", + woman_health_worker: "👩â€âš•ï¸", + student: "🧑â€ðŸŽ“", + man_student: "👨â€ðŸŽ“", + woman_student: "👩â€ðŸŽ“", + teacher: "🧑â€ðŸ«", + man_teacher: "👨â€ðŸ«", + woman_teacher: "👩â€ðŸ«", + judge: "🧑â€âš–ï¸", + man_judge: "👨â€âš–ï¸", + woman_judge: "👩â€âš–ï¸", + farmer: "🧑â€ðŸŒ¾", + man_farmer: "👨â€ðŸŒ¾", + woman_farmer: "👩â€ðŸŒ¾", + cook: "🧑â€ðŸ³", + man_cook: "👨â€ðŸ³", + woman_cook: "👩â€ðŸ³", + mechanic: "🧑â€ðŸ”§", + man_mechanic: "👨â€ðŸ”§", + woman_mechanic: "👩â€ðŸ”§", + factory_worker: "🧑â€ðŸ", + man_factory_worker: "👨â€ðŸ", + woman_factory_worker: "👩â€ðŸ", + office_worker: "🧑â€ðŸ’¼", + man_office_worker: "👨â€ðŸ’¼", + woman_office_worker: "👩â€ðŸ’¼", + scientist: "🧑â€ðŸ”¬", + man_scientist: "👨â€ðŸ”¬", + woman_scientist: "👩â€ðŸ”¬", + technologist: "🧑â€ðŸ’»", + man_technologist: "👨â€ðŸ’»", + woman_technologist: "👩â€ðŸ’»", + singer: "🧑â€ðŸŽ¤", + man_singer: "👨â€ðŸŽ¤", + woman_singer: "👩â€ðŸŽ¤", + artist: "🧑â€ðŸŽ¨", + man_artist: "👨â€ðŸŽ¨", + woman_artist: "👩â€ðŸŽ¨", + pilot: "🧑â€âœˆï¸", + man_pilot: "👨â€âœˆï¸", + woman_pilot: "👩â€âœˆï¸", + astronaut: "🧑â€ðŸš€", + man_astronaut: "👨â€ðŸš€", + woman_astronaut: "👩â€ðŸš€", + firefighter: "🧑â€ðŸš’", + man_firefighter: "👨â€ðŸš’", + woman_firefighter: "👩â€ðŸš’", + police_officer: "👮", + cop: "👮", + policeman: "👮â€â™‚ï¸", + policewoman: "👮â€â™€ï¸", + detective: "🕵ï¸", + male_detective: "🕵ï¸â€â™‚ï¸", + female_detective: "🕵ï¸â€â™€ï¸", + guard: "💂", + guardsman: "💂â€â™‚ï¸", + guardswoman: "💂â€â™€ï¸", + ninja: "🥷", + construction_worker: "👷", + construction_worker_man: "👷â€â™‚ï¸", + construction_worker_woman: "👷â€â™€ï¸", + prince: "🤴", + princess: "👸", + person_with_turban: "👳", + man_with_turban: "👳â€â™‚ï¸", + woman_with_turban: "👳â€â™€ï¸", + man_with_gua_pi_mao: "👲", + woman_with_headscarf: "🧕", + person_in_tuxedo: "🤵", + man_in_tuxedo: "🤵â€â™‚ï¸", + woman_in_tuxedo: "🤵â€â™€ï¸", + person_with_veil: "👰", + man_with_veil: "👰â€â™‚ï¸", + woman_with_veil: "👰â€â™€ï¸", + bride_with_veil: "👰â€â™€ï¸", + pregnant_woman: "🤰", + breast_feeding: "🤱", + woman_feeding_baby: "👩â€ðŸ¼", + man_feeding_baby: "👨â€ðŸ¼", + person_feeding_baby: "🧑â€ðŸ¼", + angel: "👼", + santa: "🎅", + mrs_claus: "🤶", + mx_claus: "🧑â€ðŸŽ„", + superhero: "🦸", + superhero_man: "🦸â€â™‚ï¸", + superhero_woman: "🦸â€â™€ï¸", + supervillain: "🦹", + supervillain_man: "🦹â€â™‚ï¸", + supervillain_woman: "🦹â€â™€ï¸", + mage: "🧙", + mage_man: "🧙â€â™‚ï¸", + mage_woman: "🧙â€â™€ï¸", + fairy: "🧚", + fairy_man: "🧚â€â™‚ï¸", + fairy_woman: "🧚â€â™€ï¸", + vampire: "🧛", + vampire_man: "🧛â€â™‚ï¸", + vampire_woman: "🧛â€â™€ï¸", + merperson: "🧜", + merman: "🧜â€â™‚ï¸", + mermaid: "🧜â€â™€ï¸", + elf: "ðŸ§", + elf_man: "ðŸ§â€â™‚ï¸", + elf_woman: "ðŸ§â€â™€ï¸", + genie: "🧞", + genie_man: "🧞â€â™‚ï¸", + genie_woman: "🧞â€â™€ï¸", + zombie: "🧟", + zombie_man: "🧟â€â™‚ï¸", + zombie_woman: "🧟â€â™€ï¸", + massage: "💆", + massage_man: "💆â€â™‚ï¸", + massage_woman: "💆â€â™€ï¸", + haircut: "💇", + haircut_man: "💇â€â™‚ï¸", + haircut_woman: "💇â€â™€ï¸", + walking: "🚶", + walking_man: "🚶â€â™‚ï¸", + walking_woman: "🚶â€â™€ï¸", + standing_person: "ðŸ§", + standing_man: "ðŸ§â€â™‚ï¸", + standing_woman: "ðŸ§â€â™€ï¸", + kneeling_person: "🧎", + kneeling_man: "🧎â€â™‚ï¸", + kneeling_woman: "🧎â€â™€ï¸", + person_with_probing_cane: "🧑â€ðŸ¦¯", + man_with_probing_cane: "👨â€ðŸ¦¯", + woman_with_probing_cane: "👩â€ðŸ¦¯", + person_in_motorized_wheelchair: "🧑â€ðŸ¦¼", + man_in_motorized_wheelchair: "👨â€ðŸ¦¼", + woman_in_motorized_wheelchair: "👩â€ðŸ¦¼", + person_in_manual_wheelchair: "🧑â€ðŸ¦½", + man_in_manual_wheelchair: "👨â€ðŸ¦½", + woman_in_manual_wheelchair: "👩â€ðŸ¦½", + runner: "ðŸƒ", + running: "ðŸƒ", + running_man: "ðŸƒâ€â™‚ï¸", + running_woman: "ðŸƒâ€â™€ï¸", + woman_dancing: "💃", + dancer: "💃", + man_dancing: "🕺", + business_suit_levitating: "🕴ï¸", + dancers: "👯", + dancing_men: "👯â€â™‚ï¸", + dancing_women: "👯â€â™€ï¸", + sauna_person: "🧖", + sauna_man: "🧖â€â™‚ï¸", + sauna_woman: "🧖â€â™€ï¸", + climbing: "🧗", + climbing_man: "🧗â€â™‚ï¸", + climbing_woman: "🧗â€â™€ï¸", + person_fencing: "🤺", + horse_racing: "ðŸ‡", + skier: "â›·ï¸", + snowboarder: "ðŸ‚", + golfing: "ðŸŒï¸", + golfing_man: "ðŸŒï¸â€â™‚ï¸", + golfing_woman: "ðŸŒï¸â€â™€ï¸", + surfer: "ðŸ„", + surfing_man: "ðŸ„â€â™‚ï¸", + surfing_woman: "ðŸ„â€â™€ï¸", + rowboat: "🚣", + rowing_man: "🚣â€â™‚ï¸", + rowing_woman: "🚣â€â™€ï¸", + swimmer: "ðŸŠ", + swimming_man: "ðŸŠâ€â™‚ï¸", + swimming_woman: "ðŸŠâ€â™€ï¸", + bouncing_ball_person: "⛹ï¸", + bouncing_ball_man: "⛹ï¸â€â™‚ï¸", + basketball_man: "⛹ï¸â€â™‚ï¸", + bouncing_ball_woman: "⛹ï¸â€â™€ï¸", + basketball_woman: "⛹ï¸â€â™€ï¸", + weight_lifting: "ðŸ‹ï¸", + weight_lifting_man: "ðŸ‹ï¸â€â™‚ï¸", + weight_lifting_woman: "ðŸ‹ï¸â€â™€ï¸", + bicyclist: "🚴", + biking_man: "🚴â€â™‚ï¸", + biking_woman: "🚴â€â™€ï¸", + mountain_bicyclist: "🚵", + mountain_biking_man: "🚵â€â™‚ï¸", + mountain_biking_woman: "🚵â€â™€ï¸", + cartwheeling: "🤸", + man_cartwheeling: "🤸â€â™‚ï¸", + woman_cartwheeling: "🤸â€â™€ï¸", + wrestling: "🤼", + men_wrestling: "🤼â€â™‚ï¸", + women_wrestling: "🤼â€â™€ï¸", + water_polo: "🤽", + man_playing_water_polo: "🤽â€â™‚ï¸", + woman_playing_water_polo: "🤽â€â™€ï¸", + handball_person: "🤾", + man_playing_handball: "🤾â€â™‚ï¸", + woman_playing_handball: "🤾â€â™€ï¸", + juggling_person: "🤹", + man_juggling: "🤹â€â™‚ï¸", + woman_juggling: "🤹â€â™€ï¸", + lotus_position: "🧘", + lotus_position_man: "🧘â€â™‚ï¸", + lotus_position_woman: "🧘â€â™€ï¸", + bath: "🛀", + sleeping_bed: "🛌", + people_holding_hands: "🧑â€ðŸ¤â€ðŸ§‘", + two_women_holding_hands: "ðŸ‘", + couple: "👫", + two_men_holding_hands: "👬", + couplekiss: "ðŸ’", + couplekiss_man_woman: "👩â€â¤ï¸â€ðŸ’‹â€ðŸ‘¨", + couplekiss_man_man: "👨â€â¤ï¸â€ðŸ’‹â€ðŸ‘¨", + couplekiss_woman_woman: "👩â€â¤ï¸â€ðŸ’‹â€ðŸ‘©", + couple_with_heart: "💑", + couple_with_heart_woman_man: "👩â€â¤ï¸â€ðŸ‘¨", + couple_with_heart_man_man: "👨â€â¤ï¸â€ðŸ‘¨", + couple_with_heart_woman_woman: "👩â€â¤ï¸â€ðŸ‘©", + family: "👪", + family_man_woman_boy: "👨â€ðŸ‘©â€ðŸ‘¦", + family_man_woman_girl: "👨â€ðŸ‘©â€ðŸ‘§", + family_man_woman_girl_boy: "👨â€ðŸ‘©â€ðŸ‘§â€ðŸ‘¦", + family_man_woman_boy_boy: "👨â€ðŸ‘©â€ðŸ‘¦â€ðŸ‘¦", + family_man_woman_girl_girl: "👨â€ðŸ‘©â€ðŸ‘§â€ðŸ‘§", + family_man_man_boy: "👨â€ðŸ‘¨â€ðŸ‘¦", + family_man_man_girl: "👨â€ðŸ‘¨â€ðŸ‘§", + family_man_man_girl_boy: "👨â€ðŸ‘¨â€ðŸ‘§â€ðŸ‘¦", + family_man_man_boy_boy: "👨â€ðŸ‘¨â€ðŸ‘¦â€ðŸ‘¦", + family_man_man_girl_girl: "👨â€ðŸ‘¨â€ðŸ‘§â€ðŸ‘§", + family_woman_woman_boy: "👩â€ðŸ‘©â€ðŸ‘¦", + family_woman_woman_girl: "👩â€ðŸ‘©â€ðŸ‘§", + family_woman_woman_girl_boy: "👩â€ðŸ‘©â€ðŸ‘§â€ðŸ‘¦", + family_woman_woman_boy_boy: "👩â€ðŸ‘©â€ðŸ‘¦â€ðŸ‘¦", + family_woman_woman_girl_girl: "👩â€ðŸ‘©â€ðŸ‘§â€ðŸ‘§", + family_man_boy: "👨â€ðŸ‘¦", + family_man_boy_boy: "👨â€ðŸ‘¦â€ðŸ‘¦", + family_man_girl: "👨â€ðŸ‘§", + family_man_girl_boy: "👨â€ðŸ‘§â€ðŸ‘¦", + family_man_girl_girl: "👨â€ðŸ‘§â€ðŸ‘§", + family_woman_boy: "👩â€ðŸ‘¦", + family_woman_boy_boy: "👩â€ðŸ‘¦â€ðŸ‘¦", + family_woman_girl: "👩â€ðŸ‘§", + family_woman_girl_boy: "👩â€ðŸ‘§â€ðŸ‘¦", + family_woman_girl_girl: "👩â€ðŸ‘§â€ðŸ‘§", + speaking_head: "🗣ï¸", + bust_in_silhouette: "👤", + busts_in_silhouette: "👥", + people_hugging: "🫂", + footprints: "👣", + monkey_face: "ðŸµ", + monkey: "ðŸ’", + gorilla: "ðŸ¦", + orangutan: "🦧", + dog: "ðŸ¶", + dog2: "ðŸ•", + guide_dog: "🦮", + service_dog: "ðŸ•â€ðŸ¦º", + poodle: "ðŸ©", + wolf: "ðŸº", + fox_face: "🦊", + raccoon: "ðŸ¦", + cat: "ðŸ±", + cat2: "ðŸˆ", + black_cat: "ðŸˆâ€â¬›", + lion: "ðŸ¦", + tiger: "ðŸ¯", + tiger2: "ðŸ…", + leopard: "ðŸ†", + horse: "ðŸ´", + racehorse: "ðŸŽ", + unicorn: "🦄", + zebra: "🦓", + deer: "🦌", + bison: "🦬", + cow: "ðŸ®", + ox: "ðŸ‚", + water_buffalo: "ðŸƒ", + cow2: "ðŸ„", + pig: "ðŸ·", + pig2: "ðŸ–", + boar: "ðŸ—", + pig_nose: "ðŸ½", + ram: "ðŸ", + sheep: "ðŸ‘", + goat: "ðŸ", + dromedary_camel: "ðŸª", + camel: "ðŸ«", + llama: "🦙", + giraffe: "🦒", + elephant: "ðŸ˜", + mammoth: "🦣", + rhinoceros: "ðŸ¦", + hippopotamus: "🦛", + mouse: "ðŸ", + mouse2: "ðŸ", + rat: "ðŸ€", + hamster: "ðŸ¹", + rabbit: "ðŸ°", + rabbit2: "ðŸ‡", + chipmunk: "ðŸ¿ï¸", + beaver: "🦫", + hedgehog: "🦔", + bat: "🦇", + bear: "ðŸ»", + polar_bear: "ðŸ»â€â„ï¸", + koala: "ðŸ¨", + panda_face: "ðŸ¼", + sloth: "🦥", + otter: "🦦", + skunk: "🦨", + kangaroo: "🦘", + badger: "🦡", + feet: "ðŸ¾", + paw_prints: "ðŸ¾", + turkey: "🦃", + chicken: "ðŸ”", + rooster: "ðŸ“", + hatching_chick: "ðŸ£", + baby_chick: "ðŸ¤", + hatched_chick: "ðŸ¥", + bird: "ðŸ¦", + penguin: "ðŸ§", + dove: "🕊ï¸", + eagle: "🦅", + duck: "🦆", + swan: "🦢", + owl: "🦉", + dodo: "🦤", + feather: "🪶", + flamingo: "🦩", + peacock: "🦚", + parrot: "🦜", + frog: "ðŸ¸", + crocodile: "ðŸŠ", + turtle: "ðŸ¢", + lizard: "🦎", + snake: "ðŸ", + dragon_face: "ðŸ²", + dragon: "ðŸ‰", + sauropod: "🦕", + "t-rex": "🦖", + whale: "ðŸ³", + whale2: "ðŸ‹", + dolphin: "ðŸ¬", + flipper: "ðŸ¬", + seal: "ðŸ¦", + fish: "ðŸŸ", + tropical_fish: "ðŸ ", + blowfish: "ðŸ¡", + shark: "🦈", + octopus: "ðŸ™", + shell: "ðŸš", + snail: "ðŸŒ", + butterfly: "🦋", + bug: "ðŸ›", + ant: "ðŸœ", + bee: "ðŸ", + honeybee: "ðŸ", + beetle: "🪲", + lady_beetle: "ðŸž", + cricket: "🦗", + cockroach: "🪳", + spider: "🕷ï¸", + spider_web: "🕸ï¸", + scorpion: "🦂", + mosquito: "🦟", + fly: "🪰", + worm: "🪱", + microbe: "🦠", + bouquet: "ðŸ’", + cherry_blossom: "🌸", + white_flower: "💮", + rosette: "ðŸµï¸", + rose: "🌹", + wilted_flower: "🥀", + hibiscus: "🌺", + sunflower: "🌻", + blossom: "🌼", + tulip: "🌷", + seedling: "🌱", + potted_plant: "🪴", + evergreen_tree: "🌲", + deciduous_tree: "🌳", + palm_tree: "🌴", + cactus: "🌵", + ear_of_rice: "🌾", + herb: "🌿", + shamrock: "☘ï¸", + four_leaf_clover: "ðŸ€", + maple_leaf: "ðŸ", + fallen_leaf: "ðŸ‚", + leaves: "ðŸƒ", + grapes: "ðŸ‡", + melon: "ðŸˆ", + watermelon: "ðŸ‰", + tangerine: "ðŸŠ", + orange: "ðŸŠ", + mandarin: "ðŸŠ", + lemon: "ðŸ‹", + banana: "ðŸŒ", + pineapple: "ðŸ", + mango: "ðŸ¥", + apple: "ðŸŽ", + green_apple: "ðŸ", + pear: "ðŸ", + peach: "ðŸ‘", + cherries: "ðŸ’", + strawberry: "ðŸ“", + blueberries: "ðŸ«", + kiwi_fruit: "ðŸ¥", + tomato: "ðŸ…", + olive: "🫒", + coconut: "🥥", + avocado: "🥑", + eggplant: "ðŸ†", + potato: "🥔", + carrot: "🥕", + corn: "🌽", + hot_pepper: "🌶ï¸", + bell_pepper: "🫑", + cucumber: "🥒", + leafy_green: "🥬", + broccoli: "🥦", + garlic: "🧄", + onion: "🧅", + mushroom: "ðŸ„", + peanuts: "🥜", + chestnut: "🌰", + bread: "ðŸž", + croissant: "ðŸ¥", + baguette_bread: "🥖", + flatbread: "🫓", + pretzel: "🥨", + bagel: "🥯", + pancakes: "🥞", + waffle: "🧇", + cheese: "🧀", + meat_on_bone: "ðŸ–", + poultry_leg: "ðŸ—", + cut_of_meat: "🥩", + bacon: "🥓", + hamburger: "ðŸ”", + fries: "ðŸŸ", + pizza: "ðŸ•", + hotdog: "ðŸŒ", + sandwich: "🥪", + taco: "🌮", + burrito: "🌯", + tamale: "🫔", + stuffed_flatbread: "🥙", + falafel: "🧆", + egg: "🥚", + fried_egg: "ðŸ³", + shallow_pan_of_food: "🥘", + stew: "ðŸ²", + fondue: "🫕", + bowl_with_spoon: "🥣", + green_salad: "🥗", + popcorn: "ðŸ¿", + butter: "🧈", + salt: "🧂", + canned_food: "🥫", + bento: "ðŸ±", + rice_cracker: "ðŸ˜", + rice_ball: "ðŸ™", + rice: "ðŸš", + curry: "ðŸ›", + ramen: "ðŸœ", + spaghetti: "ðŸ", + sweet_potato: "ðŸ ", + oden: "ðŸ¢", + sushi: "ðŸ£", + fried_shrimp: "ðŸ¤", + fish_cake: "ðŸ¥", + moon_cake: "🥮", + dango: "ðŸ¡", + dumpling: "🥟", + fortune_cookie: "🥠", + takeout_box: "🥡", + crab: "🦀", + lobster: "🦞", + shrimp: "ðŸ¦", + squid: "🦑", + oyster: "🦪", + icecream: "ðŸ¦", + shaved_ice: "ðŸ§", + ice_cream: "ðŸ¨", + doughnut: "ðŸ©", + cookie: "ðŸª", + birthday: "🎂", + cake: "ðŸ°", + cupcake: "ðŸ§", + pie: "🥧", + chocolate_bar: "ðŸ«", + candy: "ðŸ¬", + lollipop: "ðŸ", + custard: "ðŸ®", + honey_pot: "ðŸ¯", + baby_bottle: "ðŸ¼", + milk_glass: "🥛", + coffee: "☕", + teapot: "🫖", + tea: "ðŸµ", + sake: "ðŸ¶", + champagne: "ðŸ¾", + wine_glass: "ðŸ·", + cocktail: "ðŸ¸", + tropical_drink: "ðŸ¹", + beer: "ðŸº", + beers: "ðŸ»", + clinking_glasses: "🥂", + tumbler_glass: "🥃", + cup_with_straw: "🥤", + bubble_tea: "🧋", + beverage_box: "🧃", + mate: "🧉", + ice_cube: "🧊", + chopsticks: "🥢", + plate_with_cutlery: "ðŸ½ï¸", + fork_and_knife: "ðŸ´", + spoon: "🥄", + hocho: "🔪", + knife: "🔪", + amphora: "ðŸº", + earth_africa: "ðŸŒ", + earth_americas: "🌎", + earth_asia: "ðŸŒ", + globe_with_meridians: "ðŸŒ", + world_map: "🗺ï¸", + japan: "🗾", + compass: "ðŸ§", + mountain_snow: "ðŸ”ï¸", + mountain: "â›°ï¸", + volcano: "🌋", + mount_fuji: "🗻", + camping: "ðŸ•ï¸", + beach_umbrella: "ðŸ–ï¸", + desert: "ðŸœï¸", + desert_island: "ðŸï¸", + national_park: "ðŸžï¸", + stadium: "ðŸŸï¸", + classical_building: "ðŸ›ï¸", + building_construction: "ðŸ—ï¸", + bricks: "🧱", + rock: "🪨", + wood: "🪵", + hut: "🛖", + houses: "ðŸ˜ï¸", + derelict_house: "ðŸšï¸", + house: "ðŸ ", + house_with_garden: "ðŸ¡", + office: "ðŸ¢", + post_office: "ðŸ£", + european_post_office: "ðŸ¤", + hospital: "ðŸ¥", + bank: "ðŸ¦", + hotel: "ðŸ¨", + love_hotel: "ðŸ©", + convenience_store: "ðŸª", + school: "ðŸ«", + department_store: "ðŸ¬", + factory: "ðŸ", + japanese_castle: "ðŸ¯", + european_castle: "ðŸ°", + wedding: "💒", + tokyo_tower: "🗼", + statue_of_liberty: "🗽", + church: "⛪", + mosque: "🕌", + hindu_temple: "🛕", + synagogue: "ðŸ•", + shinto_shrine: "⛩ï¸", + kaaba: "🕋", + fountain: "⛲", + tent: "⛺", + foggy: "ðŸŒ", + night_with_stars: "🌃", + cityscape: "ðŸ™ï¸", + sunrise_over_mountains: "🌄", + sunrise: "🌅", + city_sunset: "🌆", + city_sunrise: "🌇", + bridge_at_night: "🌉", + hotsprings: "♨ï¸", + carousel_horse: "🎠", + ferris_wheel: "🎡", + roller_coaster: "🎢", + barber: "💈", + circus_tent: "🎪", + steam_locomotive: "🚂", + railway_car: "🚃", + bullettrain_side: "🚄", + bullettrain_front: "🚅", + train2: "🚆", + metro: "🚇", + light_rail: "🚈", + station: "🚉", + tram: "🚊", + monorail: "ðŸš", + mountain_railway: "🚞", + train: "🚋", + bus: "🚌", + oncoming_bus: "ðŸš", + trolleybus: "🚎", + minibus: "ðŸš", + ambulance: "🚑", + fire_engine: "🚒", + police_car: "🚓", + oncoming_police_car: "🚔", + taxi: "🚕", + oncoming_taxi: "🚖", + car: "🚗", + red_car: "🚗", + oncoming_automobile: "🚘", + blue_car: "🚙", + pickup_truck: "🛻", + truck: "🚚", + articulated_lorry: "🚛", + tractor: "🚜", + racing_car: "ðŸŽï¸", + motorcycle: "ðŸï¸", + motor_scooter: "🛵", + manual_wheelchair: "🦽", + motorized_wheelchair: "🦼", + auto_rickshaw: "🛺", + bike: "🚲", + kick_scooter: "🛴", + skateboard: "🛹", + roller_skate: "🛼", + busstop: "ðŸš", + motorway: "🛣ï¸", + railway_track: "🛤ï¸", + oil_drum: "🛢ï¸", + fuelpump: "⛽", + rotating_light: "🚨", + traffic_light: "🚥", + vertical_traffic_light: "🚦", + stop_sign: "🛑", + construction: "🚧", + anchor: "âš“", + boat: "⛵", + sailboat: "⛵", + canoe: "🛶", + speedboat: "🚤", + passenger_ship: "🛳ï¸", + ferry: "â›´ï¸", + motor_boat: "🛥ï¸", + ship: "🚢", + airplane: "✈ï¸", + small_airplane: "🛩ï¸", + flight_departure: "🛫", + flight_arrival: "🛬", + parachute: "🪂", + seat: "💺", + helicopter: "ðŸš", + suspension_railway: "🚟", + mountain_cableway: "🚠", + aerial_tramway: "🚡", + artificial_satellite: "🛰ï¸", + rocket: "🚀", + flying_saucer: "🛸", + bellhop_bell: "🛎ï¸", + luggage: "🧳", + hourglass: "⌛", + hourglass_flowing_sand: "â³", + watch: "⌚", + alarm_clock: "â°", + stopwatch: "â±ï¸", + timer_clock: "â²ï¸", + mantelpiece_clock: "🕰ï¸", + clock12: "🕛", + clock1230: "🕧", + clock1: "ðŸ•", + clock130: "🕜", + clock2: "🕑", + clock230: "ðŸ•", + clock3: "🕒", + clock330: "🕞", + clock4: "🕓", + clock430: "🕟", + clock5: "🕔", + clock530: "🕠", + clock6: "🕕", + clock630: "🕡", + clock7: "🕖", + clock730: "🕢", + clock8: "🕗", + clock830: "🕣", + clock9: "🕘", + clock930: "🕤", + clock10: "🕙", + clock1030: "🕥", + clock11: "🕚", + clock1130: "🕦", + new_moon: "🌑", + waxing_crescent_moon: "🌒", + first_quarter_moon: "🌓", + moon: "🌔", + waxing_gibbous_moon: "🌔", + full_moon: "🌕", + waning_gibbous_moon: "🌖", + last_quarter_moon: "🌗", + waning_crescent_moon: "🌘", + crescent_moon: "🌙", + new_moon_with_face: "🌚", + first_quarter_moon_with_face: "🌛", + last_quarter_moon_with_face: "🌜", + thermometer: "🌡ï¸", + sunny: "☀ï¸", + full_moon_with_face: "ðŸŒ", + sun_with_face: "🌞", + ringed_planet: "ðŸª", + star: "â", + star2: "🌟", + stars: "🌠", + milky_way: "🌌", + cloud: "â˜ï¸", + partly_sunny: "â›…", + cloud_with_lightning_and_rain: "⛈ï¸", + sun_behind_small_cloud: "🌤ï¸", + sun_behind_large_cloud: "🌥ï¸", + sun_behind_rain_cloud: "🌦ï¸", + cloud_with_rain: "🌧ï¸", + cloud_with_snow: "🌨ï¸", + cloud_with_lightning: "🌩ï¸", + tornado: "🌪ï¸", + fog: "🌫ï¸", + wind_face: "🌬ï¸", + cyclone: "🌀", + rainbow: "🌈", + closed_umbrella: "🌂", + open_umbrella: "☂ï¸", + umbrella: "☔", + parasol_on_ground: "â›±ï¸", + zap: "âš¡", + snowflake: "â„ï¸", + snowman_with_snow: "☃ï¸", + snowman: "⛄", + comet: "☄ï¸", + fire: "🔥", + droplet: "💧", + ocean: "🌊", + jack_o_lantern: "🎃", + christmas_tree: "🎄", + fireworks: "🎆", + sparkler: "🎇", + firecracker: "🧨", + sparkles: "✨", + balloon: "🎈", + tada: "🎉", + confetti_ball: "🎊", + tanabata_tree: "🎋", + bamboo: "ðŸŽ", + dolls: "🎎", + flags: "ðŸŽ", + wind_chime: "ðŸŽ", + rice_scene: "🎑", + red_envelope: "🧧", + ribbon: "🎀", + gift: "ðŸŽ", + reminder_ribbon: "🎗ï¸", + tickets: "🎟ï¸", + ticket: "🎫", + medal_military: "🎖ï¸", + trophy: "ðŸ†", + medal_sports: "ðŸ…", + "1st_place_medal": "🥇", + "2nd_place_medal": "🥈", + "3rd_place_medal": "🥉", + soccer: "âš½", + baseball: "âš¾", + softball: "🥎", + basketball: "ðŸ€", + volleyball: "ðŸ", + football: "ðŸˆ", + rugby_football: "ðŸ‰", + tennis: "🎾", + flying_disc: "ðŸ¥", + bowling: "🎳", + cricket_game: "ðŸ", + field_hockey: "ðŸ‘", + ice_hockey: "ðŸ’", + lacrosse: "ðŸ¥", + ping_pong: "ðŸ“", + badminton: "ðŸ¸", + boxing_glove: "🥊", + martial_arts_uniform: "🥋", + goal_net: "🥅", + golf: "⛳", + ice_skate: "⛸ï¸", + fishing_pole_and_fish: "🎣", + diving_mask: "🤿", + running_shirt_with_sash: "🎽", + ski: "🎿", + sled: "🛷", + curling_stone: "🥌", + dart: "🎯", + yo_yo: "🪀", + kite: "ðŸª", + "8ball": "🎱", + crystal_ball: "🔮", + magic_wand: "🪄", + nazar_amulet: "🧿", + video_game: "🎮", + joystick: "🕹ï¸", + slot_machine: "🎰", + game_die: "🎲", + jigsaw: "🧩", + teddy_bear: "🧸", + pinata: "🪅", + nesting_dolls: "🪆", + spades: "â™ ï¸", + hearts: "♥ï¸", + diamonds: "♦ï¸", + clubs: "♣ï¸", + chess_pawn: "♟ï¸", + black_joker: "ðŸƒ", + mahjong: "🀄", + flower_playing_cards: "🎴", + performing_arts: "ðŸŽ", + framed_picture: "🖼ï¸", + art: "🎨", + thread: "🧵", + sewing_needle: "🪡", + yarn: "🧶", + knot: "🪢", + eyeglasses: "👓", + dark_sunglasses: "🕶ï¸", + goggles: "🥽", + lab_coat: "🥼", + safety_vest: "🦺", + necktie: "👔", + shirt: "👕", + tshirt: "👕", + jeans: "👖", + scarf: "🧣", + gloves: "🧤", + coat: "🧥", + socks: "🧦", + dress: "👗", + kimono: "👘", + sari: "🥻", + one_piece_swimsuit: "🩱", + swim_brief: "🩲", + shorts: "🩳", + bikini: "👙", + womans_clothes: "👚", + purse: "👛", + handbag: "👜", + pouch: "ðŸ‘", + shopping: "ðŸ›ï¸", + school_satchel: "🎒", + thong_sandal: "🩴", + mans_shoe: "👞", + shoe: "👞", + athletic_shoe: "👟", + hiking_boot: "🥾", + flat_shoe: "🥿", + high_heel: "👠", + sandal: "👡", + ballet_shoes: "🩰", + boot: "👢", + crown: "👑", + womans_hat: "👒", + tophat: "🎩", + mortar_board: "🎓", + billed_cap: "🧢", + military_helmet: "🪖", + rescue_worker_helmet: "⛑ï¸", + prayer_beads: "📿", + lipstick: "💄", + ring: "ðŸ’", + gem: "💎", + mute: "🔇", + speaker: "🔈", + sound: "🔉", + loud_sound: "🔊", + loudspeaker: "📢", + mega: "📣", + postal_horn: "📯", + bell: "🔔", + no_bell: "🔕", + musical_score: "🎼", + musical_note: "🎵", + notes: "🎶", + studio_microphone: "🎙ï¸", + level_slider: "🎚ï¸", + control_knobs: "🎛ï¸", + microphone: "🎤", + headphones: "🎧", + radio: "📻", + saxophone: "🎷", + accordion: "🪗", + guitar: "🎸", + musical_keyboard: "🎹", + trumpet: "🎺", + violin: "🎻", + banjo: "🪕", + drum: "ðŸ¥", + long_drum: "🪘", + iphone: "📱", + calling: "📲", + phone: "☎ï¸", + telephone: "☎ï¸", + telephone_receiver: "📞", + pager: "📟", + fax: "📠", + battery: "🔋", + electric_plug: "🔌", + computer: "💻", + desktop_computer: "🖥ï¸", + printer: "🖨ï¸", + keyboard: "⌨ï¸", + computer_mouse: "🖱ï¸", + trackball: "🖲ï¸", + minidisc: "💽", + floppy_disk: "💾", + cd: "💿", + dvd: "📀", + abacus: "🧮", + movie_camera: "🎥", + film_strip: "🎞ï¸", + film_projector: "📽ï¸", + clapper: "🎬", + tv: "📺", + camera: "📷", + camera_flash: "📸", + video_camera: "📹", + vhs: "📼", + mag: "ðŸ”", + mag_right: "🔎", + candle: "🕯ï¸", + bulb: "💡", + flashlight: "🔦", + izakaya_lantern: "ðŸ®", + lantern: "ðŸ®", + diya_lamp: "🪔", + notebook_with_decorative_cover: "📔", + closed_book: "📕", + book: "📖", + open_book: "📖", + green_book: "📗", + blue_book: "📘", + orange_book: "📙", + books: "📚", + notebook: "📓", + ledger: "📒", + page_with_curl: "📃", + scroll: "📜", + page_facing_up: "📄", + newspaper: "📰", + newspaper_roll: "🗞ï¸", + bookmark_tabs: "📑", + bookmark: "🔖", + label: "ðŸ·ï¸", + moneybag: "💰", + coin: "🪙", + yen: "💴", + dollar: "💵", + euro: "💶", + pound: "💷", + money_with_wings: "💸", + credit_card: "💳", + receipt: "🧾", + chart: "💹", + envelope: "✉ï¸", + email: "📧", + "e-mail": "📧", + incoming_envelope: "📨", + envelope_with_arrow: "📩", + outbox_tray: "📤", + inbox_tray: "📥", + package: "📦", + mailbox: "📫", + mailbox_closed: "📪", + mailbox_with_mail: "📬", + mailbox_with_no_mail: "ðŸ“", + postbox: "📮", + ballot_box: "🗳ï¸", + pencil2: "âœï¸", + black_nib: "✒ï¸", + fountain_pen: "🖋ï¸", + pen: "🖊ï¸", + paintbrush: "🖌ï¸", + crayon: "ðŸ–ï¸", + memo: "ðŸ“", + pencil: "ðŸ“", + briefcase: "💼", + file_folder: "ðŸ“", + open_file_folder: "📂", + card_index_dividers: "🗂ï¸", + date: "📅", + calendar: "📆", + spiral_notepad: "🗒ï¸", + spiral_calendar: "🗓ï¸", + card_index: "📇", + chart_with_upwards_trend: "📈", + chart_with_downwards_trend: "📉", + bar_chart: "📊", + clipboard: "📋", + pushpin: "📌", + round_pushpin: "ðŸ“", + paperclip: "📎", + paperclips: "🖇ï¸", + straight_ruler: "ðŸ“", + triangular_ruler: "ðŸ“", + scissors: "✂ï¸", + card_file_box: "🗃ï¸", + file_cabinet: "🗄ï¸", + wastebasket: "🗑ï¸", + lock: "🔒", + unlock: "🔓", + lock_with_ink_pen: "ðŸ”", + closed_lock_with_key: "ðŸ”", + key: "🔑", + old_key: "ðŸ—ï¸", + hammer: "🔨", + axe: "🪓", + pick: "â›ï¸", + hammer_and_pick: "âš’ï¸", + hammer_and_wrench: "🛠ï¸", + dagger: "🗡ï¸", + crossed_swords: "âš”ï¸", + gun: "🔫", + boomerang: "🪃", + bow_and_arrow: "ðŸ¹", + shield: "🛡ï¸", + carpentry_saw: "🪚", + wrench: "🔧", + screwdriver: "🪛", + nut_and_bolt: "🔩", + gear: "âš™ï¸", + clamp: "🗜ï¸", + balance_scale: "âš–ï¸", + probing_cane: "🦯", + link: "🔗", + chains: "⛓ï¸", + hook: "ðŸª", + toolbox: "🧰", + magnet: "🧲", + ladder: "🪜", + alembic: "âš—ï¸", + test_tube: "🧪", + petri_dish: "🧫", + dna: "🧬", + microscope: "🔬", + telescope: "ðŸ”", + satellite: "📡", + syringe: "💉", + drop_of_blood: "🩸", + pill: "💊", + adhesive_bandage: "🩹", + stethoscope: "🩺", + door: "🚪", + elevator: "🛗", + mirror: "🪞", + window: "🪟", + bed: "ðŸ›ï¸", + couch_and_lamp: "🛋ï¸", + chair: "🪑", + toilet: "🚽", + plunger: "🪠", + shower: "🚿", + bathtub: "ðŸ›", + mouse_trap: "🪤", + razor: "🪒", + lotion_bottle: "🧴", + safety_pin: "🧷", + broom: "🧹", + basket: "🧺", + roll_of_paper: "🧻", + bucket: "🪣", + soap: "🧼", + toothbrush: "🪥", + sponge: "🧽", + fire_extinguisher: "🧯", + shopping_cart: "🛒", + smoking: "🚬", + coffin: "âš°ï¸", + headstone: "🪦", + funeral_urn: "âš±ï¸", + moyai: "🗿", + placard: "🪧", + atm: "ðŸ§", + put_litter_in_its_place: "🚮", + potable_water: "🚰", + wheelchair: "♿", + mens: "🚹", + womens: "🚺", + restroom: "🚻", + baby_symbol: "🚼", + wc: "🚾", + passport_control: "🛂", + customs: "🛃", + baggage_claim: "🛄", + left_luggage: "🛅", + warning: "âš ï¸", + children_crossing: "🚸", + no_entry: "â›”", + no_entry_sign: "🚫", + no_bicycles: "🚳", + no_smoking: "ðŸš", + do_not_litter: "🚯", + "non-potable_water": "🚱", + no_pedestrians: "🚷", + no_mobile_phones: "📵", + underage: "🔞", + radioactive: "☢ï¸", + biohazard: "☣ï¸", + arrow_up: "⬆ï¸", + arrow_upper_right: "↗ï¸", + arrow_right: "âž¡ï¸", + arrow_lower_right: "↘ï¸", + arrow_down: "⬇ï¸", + arrow_lower_left: "↙ï¸", + arrow_left: "⬅ï¸", + arrow_upper_left: "↖ï¸", + arrow_up_down: "↕ï¸", + left_right_arrow: "↔ï¸", + leftwards_arrow_with_hook: "↩ï¸", + arrow_right_hook: "↪ï¸", + arrow_heading_up: "⤴ï¸", + arrow_heading_down: "⤵ï¸", + arrows_clockwise: "🔃", + arrows_counterclockwise: "🔄", + back: "🔙", + end: "🔚", + on: "🔛", + soon: "🔜", + top: "ðŸ”", + place_of_worship: "ðŸ›", + atom_symbol: "âš›ï¸", + om: "🕉ï¸", + star_of_david: "✡ï¸", + wheel_of_dharma: "☸ï¸", + yin_yang: "☯ï¸", + latin_cross: "âœï¸", + orthodox_cross: "☦ï¸", + star_and_crescent: "☪ï¸", + peace_symbol: "☮ï¸", + menorah: "🕎", + six_pointed_star: "🔯", + aries: "♈", + taurus: "♉", + gemini: "♊", + cancer: "♋", + leo: "♌", + virgo: "â™", + libra: "♎", + scorpius: "â™", + sagittarius: "â™", + capricorn: "♑", + aquarius: "â™’", + pisces: "♓", + ophiuchus: "⛎", + twisted_rightwards_arrows: "🔀", + repeat: "ðŸ”", + repeat_one: "🔂", + arrow_forward: "â–¶ï¸", + fast_forward: "â©", + next_track_button: "âï¸", + play_or_pause_button: "â¯ï¸", + arrow_backward: "â—€ï¸", + rewind: "âª", + previous_track_button: "â®ï¸", + arrow_up_small: "🔼", + arrow_double_up: "â«", + arrow_down_small: "🔽", + arrow_double_down: "â¬", + pause_button: "â¸ï¸", + stop_button: "â¹ï¸", + record_button: "âºï¸", + eject_button: "âï¸", + cinema: "🎦", + low_brightness: "🔅", + high_brightness: "🔆", + signal_strength: "📶", + vibration_mode: "📳", + mobile_phone_off: "📴", + female_sign: "♀ï¸", + male_sign: "♂ï¸", + transgender_symbol: "âš§ï¸", + heavy_multiplication_x: "✖ï¸", + heavy_plus_sign: "âž•", + heavy_minus_sign: "âž–", + heavy_division_sign: "âž—", + infinity: "♾ï¸", + bangbang: "‼ï¸", + interrobang: "â‰ï¸", + question: "â“", + grey_question: "â”", + grey_exclamation: "â•", + exclamation: "â—", + heavy_exclamation_mark: "â—", + wavy_dash: "〰ï¸", + currency_exchange: "💱", + heavy_dollar_sign: "💲", + medical_symbol: "âš•ï¸", + recycle: "â™»ï¸", + fleur_de_lis: "âšœï¸", + trident: "🔱", + name_badge: "📛", + beginner: "🔰", + o: "â•", + white_check_mark: "✅", + ballot_box_with_check: "☑ï¸", + heavy_check_mark: "✔ï¸", + x: "âŒ", + negative_squared_cross_mark: "âŽ", + curly_loop: "âž°", + loop: "âž¿", + part_alternation_mark: "〽ï¸", + eight_spoked_asterisk: "✳ï¸", + eight_pointed_black_star: "✴ï¸", + sparkle: "â‡ï¸", + copyright: "©ï¸", + registered: "®ï¸", + tm: "â„¢ï¸", + hash: "#ï¸âƒ£", + asterisk: "*ï¸âƒ£", + zero: "0ï¸âƒ£", + one: "1ï¸âƒ£", + two: "2ï¸âƒ£", + three: "3ï¸âƒ£", + four: "4ï¸âƒ£", + five: "5ï¸âƒ£", + six: "6ï¸âƒ£", + seven: "7ï¸âƒ£", + eight: "8ï¸âƒ£", + nine: "9ï¸âƒ£", + keycap_ten: "🔟", + capital_abcd: "🔠", + abcd: "🔡", + symbols: "🔣", + abc: "🔤", + a: "🅰ï¸", + ab: "🆎", + b: "🅱ï¸", + cl: "🆑", + cool: "🆒", + free: "🆓", + information_source: "ℹï¸", + id: "🆔", + m: "â“‚ï¸", + new: "🆕", + ng: "🆖", + o2: "🅾ï¸", + ok: "🆗", + parking: "🅿ï¸", + sos: "🆘", + up: "🆙", + vs: "🆚", + koko: "ðŸˆ", + sa: "🈂ï¸", + u6708: "🈷ï¸", + u6709: "🈶", + u6307: "🈯", + ideograph_advantage: "ðŸ‰", + u5272: "🈹", + u7121: "🈚", + u7981: "🈲", + accept: "🉑", + u7533: "🈸", + u5408: "🈴", + u7a7a: "🈳", + congratulations: "㊗ï¸", + secret: "㊙ï¸", + u55b6: "🈺", + u6e80: "🈵", + red_circle: "🔴", + orange_circle: "🟠", + yellow_circle: "🟡", + green_circle: "🟢", + large_blue_circle: "🔵", + purple_circle: "🟣", + brown_circle: "🟤", + black_circle: "âš«", + white_circle: "⚪", + red_square: "🟥", + orange_square: "🟧", + yellow_square: "🟨", + green_square: "🟩", + blue_square: "🟦", + purple_square: "🟪", + brown_square: "🟫", + black_large_square: "⬛", + white_large_square: "⬜", + black_medium_square: "â—¼ï¸", + white_medium_square: "â—»ï¸", + black_medium_small_square: "â—¾", + white_medium_small_square: "â—½", + black_small_square: "â–ªï¸", + white_small_square: "â–«ï¸", + large_orange_diamond: "🔶", + large_blue_diamond: "🔷", + small_orange_diamond: "🔸", + small_blue_diamond: "🔹", + small_red_triangle: "🔺", + small_red_triangle_down: "🔻", + diamond_shape_with_a_dot_inside: "💠", + radio_button: "🔘", + white_square_button: "🔳", + black_square_button: "🔲", + checkered_flag: "ðŸ", + triangular_flag_on_post: "🚩", + crossed_flags: "🎌", + black_flag: "ðŸ´", + white_flag: "ðŸ³ï¸", + rainbow_flag: "ðŸ³ï¸â€ðŸŒˆ", + transgender_flag: "ðŸ³ï¸â€âš§ï¸", + pirate_flag: "ðŸ´â€â˜ ï¸", + ascension_island: "🇦🇨", + andorra: "🇦🇩", + united_arab_emirates: "🇦🇪", + afghanistan: "🇦🇫", + antigua_barbuda: "🇦🇬", + anguilla: "🇦🇮", + albania: "🇦🇱", + armenia: "🇦🇲", + angola: "🇦🇴", + antarctica: "🇦🇶", + argentina: "🇦🇷", + american_samoa: "🇦🇸", + austria: "🇦🇹", + australia: "🇦🇺", + aruba: "🇦🇼", + aland_islands: "🇦🇽", + azerbaijan: "🇦🇿", + bosnia_herzegovina: "🇧🇦", + barbados: "🇧🇧", + bangladesh: "🇧🇩", + belgium: "🇧🇪", + burkina_faso: "🇧🇫", + bulgaria: "🇧🇬", + bahrain: "🇧ðŸ‡", + burundi: "🇧🇮", + benin: "🇧🇯", + st_barthelemy: "🇧🇱", + bermuda: "🇧🇲", + brunei: "🇧🇳", + bolivia: "🇧🇴", + caribbean_netherlands: "🇧🇶", + brazil: "🇧🇷", + bahamas: "🇧🇸", + bhutan: "🇧🇹", + bouvet_island: "🇧🇻", + botswana: "🇧🇼", + belarus: "🇧🇾", + belize: "🇧🇿", + canada: "🇨🇦", + cocos_islands: "🇨🇨", + congo_kinshasa: "🇨🇩", + central_african_republic: "🇨🇫", + congo_brazzaville: "🇨🇬", + switzerland: "🇨ðŸ‡", + cote_divoire: "🇨🇮", + cook_islands: "🇨🇰", + chile: "🇨🇱", + cameroon: "🇨🇲", + cn: "🇨🇳", + colombia: "🇨🇴", + clipperton_island: "🇨🇵", + costa_rica: "🇨🇷", + cuba: "🇨🇺", + cape_verde: "🇨🇻", + curacao: "🇨🇼", + christmas_island: "🇨🇽", + cyprus: "🇨🇾", + czech_republic: "🇨🇿", + de: "🇩🇪", + diego_garcia: "🇩🇬", + djibouti: "🇩🇯", + denmark: "🇩🇰", + dominica: "🇩🇲", + dominican_republic: "🇩🇴", + algeria: "🇩🇿", + ceuta_melilla: "🇪🇦", + ecuador: "🇪🇨", + estonia: "🇪🇪", + egypt: "🇪🇬", + western_sahara: "🇪ðŸ‡", + eritrea: "🇪🇷", + es: "🇪🇸", + ethiopia: "🇪🇹", + eu: "🇪🇺", + european_union: "🇪🇺", + finland: "🇫🇮", + fiji: "🇫🇯", + falkland_islands: "🇫🇰", + micronesia: "🇫🇲", + faroe_islands: "🇫🇴", + fr: "🇫🇷", + gabon: "🇬🇦", + gb: "🇬🇧", + uk: "🇬🇧", + grenada: "🇬🇩", + georgia: "🇬🇪", + french_guiana: "🇬🇫", + guernsey: "🇬🇬", + ghana: "🇬ðŸ‡", + gibraltar: "🇬🇮", + greenland: "🇬🇱", + gambia: "🇬🇲", + guinea: "🇬🇳", + guadeloupe: "🇬🇵", + equatorial_guinea: "🇬🇶", + greece: "🇬🇷", + south_georgia_south_sandwich_islands: "🇬🇸", + guatemala: "🇬🇹", + guam: "🇬🇺", + guinea_bissau: "🇬🇼", + guyana: "🇬🇾", + hong_kong: "ðŸ‡ðŸ‡°", + heard_mcdonald_islands: "ðŸ‡ðŸ‡²", + honduras: "ðŸ‡ðŸ‡³", + croatia: "ðŸ‡ðŸ‡·", + haiti: "ðŸ‡ðŸ‡¹", + hungary: "ðŸ‡ðŸ‡º", + canary_islands: "🇮🇨", + indonesia: "🇮🇩", + ireland: "🇮🇪", + israel: "🇮🇱", + isle_of_man: "🇮🇲", + india: "🇮🇳", + british_indian_ocean_territory: "🇮🇴", + iraq: "🇮🇶", + iran: "🇮🇷", + iceland: "🇮🇸", + it: "🇮🇹", + jersey: "🇯🇪", + jamaica: "🇯🇲", + jordan: "🇯🇴", + jp: "🇯🇵", + kenya: "🇰🇪", + kyrgyzstan: "🇰🇬", + cambodia: "🇰ðŸ‡", + kiribati: "🇰🇮", + comoros: "🇰🇲", + st_kitts_nevis: "🇰🇳", + north_korea: "🇰🇵", + kr: "🇰🇷", + kuwait: "🇰🇼", + cayman_islands: "🇰🇾", + kazakhstan: "🇰🇿", + laos: "🇱🇦", + lebanon: "🇱🇧", + st_lucia: "🇱🇨", + liechtenstein: "🇱🇮", + sri_lanka: "🇱🇰", + liberia: "🇱🇷", + lesotho: "🇱🇸", + lithuania: "🇱🇹", + luxembourg: "🇱🇺", + latvia: "🇱🇻", + libya: "🇱🇾", + morocco: "🇲🇦", + monaco: "🇲🇨", + moldova: "🇲🇩", + montenegro: "🇲🇪", + st_martin: "🇲🇫", + madagascar: "🇲🇬", + marshall_islands: "🇲ðŸ‡", + macedonia: "🇲🇰", + mali: "🇲🇱", + myanmar: "🇲🇲", + mongolia: "🇲🇳", + macau: "🇲🇴", + northern_mariana_islands: "🇲🇵", + martinique: "🇲🇶", + mauritania: "🇲🇷", + montserrat: "🇲🇸", + malta: "🇲🇹", + mauritius: "🇲🇺", + maldives: "🇲🇻", + malawi: "🇲🇼", + mexico: "🇲🇽", + malaysia: "🇲🇾", + mozambique: "🇲🇿", + namibia: "🇳🇦", + new_caledonia: "🇳🇨", + niger: "🇳🇪", + norfolk_island: "🇳🇫", + nigeria: "🇳🇬", + nicaragua: "🇳🇮", + netherlands: "🇳🇱", + norway: "🇳🇴", + nepal: "🇳🇵", + nauru: "🇳🇷", + niue: "🇳🇺", + new_zealand: "🇳🇿", + oman: "🇴🇲", + panama: "🇵🇦", + peru: "🇵🇪", + french_polynesia: "🇵🇫", + papua_new_guinea: "🇵🇬", + philippines: "🇵ðŸ‡", + pakistan: "🇵🇰", + poland: "🇵🇱", + st_pierre_miquelon: "🇵🇲", + pitcairn_islands: "🇵🇳", + puerto_rico: "🇵🇷", + palestinian_territories: "🇵🇸", + portugal: "🇵🇹", + palau: "🇵🇼", + paraguay: "🇵🇾", + qatar: "🇶🇦", + reunion: "🇷🇪", + romania: "🇷🇴", + serbia: "🇷🇸", + ru: "🇷🇺", + rwanda: "🇷🇼", + saudi_arabia: "🇸🇦", + solomon_islands: "🇸🇧", + seychelles: "🇸🇨", + sudan: "🇸🇩", + sweden: "🇸🇪", + singapore: "🇸🇬", + st_helena: "🇸ðŸ‡", + slovenia: "🇸🇮", + svalbard_jan_mayen: "🇸🇯", + slovakia: "🇸🇰", + sierra_leone: "🇸🇱", + san_marino: "🇸🇲", + senegal: "🇸🇳", + somalia: "🇸🇴", + suriname: "🇸🇷", + south_sudan: "🇸🇸", + sao_tome_principe: "🇸🇹", + el_salvador: "🇸🇻", + sint_maarten: "🇸🇽", + syria: "🇸🇾", + swaziland: "🇸🇿", + tristan_da_cunha: "🇹🇦", + turks_caicos_islands: "🇹🇨", + chad: "🇹🇩", + french_southern_territories: "🇹🇫", + togo: "🇹🇬", + thailand: "🇹ðŸ‡", + tajikistan: "🇹🇯", + tokelau: "🇹🇰", + timor_leste: "🇹🇱", + turkmenistan: "🇹🇲", + tunisia: "🇹🇳", + tonga: "🇹🇴", + tr: "🇹🇷", + trinidad_tobago: "🇹🇹", + tuvalu: "🇹🇻", + taiwan: "🇹🇼", + tanzania: "🇹🇿", + ukraine: "🇺🇦", + uganda: "🇺🇬", + us_outlying_islands: "🇺🇲", + united_nations: "🇺🇳", + us: "🇺🇸", + uruguay: "🇺🇾", + uzbekistan: "🇺🇿", + vatican_city: "🇻🇦", + st_vincent_grenadines: "🇻🇨", + venezuela: "🇻🇪", + british_virgin_islands: "🇻🇬", + us_virgin_islands: "🇻🇮", + vietnam: "🇻🇳", + vanuatu: "🇻🇺", + wallis_futuna: "🇼🇫", + samoa: "🇼🇸", + kosovo: "🇽🇰", + yemen: "🇾🇪", + mayotte: "🇾🇹", + south_africa: "🇿🇦", + zambia: "🇿🇲", + zimbabwe: "🇿🇼", + england: "ðŸ´ó §ó ¢ó ¥ó ®ó §ó ¿", + scotland: "ðŸ´ó §ó ¢ó ³ó £ó ´ó ¿", + wales: "ðŸ´ó §ó ¢ó ·ó ¬ó ³ó ¿", +}; diff --git a/src/assets/sounds/Audio.ts b/src/assets/sounds/Audio.ts index 1168c6c614fe1d436c2f5e4429666f5a29ce74e3..be4881b3439ac821677c0ed8f0db2e426c2eb2a2 100644 --- a/src/assets/sounds/Audio.ts +++ b/src/assets/sounds/Audio.ts @@ -1,24 +1,29 @@ -import message from './message.mp3'; -import outbound from './outbound.mp3'; -import call_join from './call_join.mp3'; -import call_leave from './call_leave.mp3'; +import call_join from "./call_join.mp3"; +import call_leave from "./call_leave.mp3"; +import message from "./message.mp3"; +import outbound from "./outbound.mp3"; const SoundMap: { [key in Sounds]: string } = { - message, - outbound, - call_join, - call_leave -} + message, + outbound, + call_join, + call_leave, +}; -export type Sounds = 'message' | 'outbound' | 'call_join' | 'call_leave'; -export const SOUNDS_ARRAY: Sounds[] = [ 'message', 'outbound', 'call_join', 'call_leave' ]; +export type Sounds = "message" | "outbound" | "call_join" | "call_leave"; +export const SOUNDS_ARRAY: Sounds[] = [ + "message", + "outbound", + "call_join", + "call_leave", +]; export function playSound(sound: Sounds) { - let file = SoundMap[sound]; - let el = new Audio(file); - try { - el.play(); - } catch (err) { - console.error('Failed to play audio file', file, err); - } + let file = SoundMap[sound]; + let el = new Audio(file); + try { + el.play(); + } catch (err) { + console.error("Failed to play audio file", file, err); + } } diff --git a/src/components/common/AutoComplete.tsx b/src/components/common/AutoComplete.tsx index 565f4a4ac2fd34f8ba0694aaaf61c088f457c47e..419a969c66e17f42b360df4add3fb85131ff3c1d 100644 --- a/src/components/common/AutoComplete.tsx +++ b/src/components/common/AutoComplete.tsx @@ -1,449 +1,475 @@ +import { SYSTEM_USER_ID, User } from "revolt.js"; +import { Channels } from "revolt.js/dist/api/objects"; +import styled, { css } from "styled-components"; + import { StateUpdater, useContext, useState } from "preact/hooks"; + import { AppContext } from "../../context/revoltjs/RevoltClient"; -import { Channels } from "revolt.js/dist/api/objects"; + import { emojiDictionary } from "../../assets/emojis"; -import { SYSTEM_USER_ID, User } from "revolt.js"; -import UserIcon from "./user/UserIcon"; -import styled, { css } from "styled-components"; -import Emoji from "./Emoji"; import ChannelIcon from "./ChannelIcon"; +import Emoji from "./Emoji"; +import UserIcon from "./user/UserIcon"; export type AutoCompleteState = - | { type: "none" } - | ({ selected: number; within: boolean; } & ( - { - type: "emoji"; - matches: string[]; - } | - { - type: "user"; - matches: User[]; - } | - { - type: "channel"; - matches: Channels.TextChannel[]; - } - )); + | { type: "none" } + | ({ selected: number; within: boolean } & ( + | { + type: "emoji"; + matches: string[]; + } + | { + type: "user"; + matches: User[]; + } + | { + type: "channel"; + matches: Channels.TextChannel[]; + } + )); export type SearchClues = { - users?: { type: 'channel', id: string } | { type: 'all' }, - channels?: { server: string } + users?: { type: "channel"; id: string } | { type: "all" }; + channels?: { server: string }; }; export type AutoCompleteProps = { - detached?: boolean, - state: AutoCompleteState, - setState: StateUpdater<AutoCompleteState>, - - onKeyUp: (ev: KeyboardEvent) => void, - onKeyDown: (ev: KeyboardEvent) => boolean, - onChange: (ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => void, - onClick: JSX.MouseEventHandler<HTMLButtonElement>, - onFocus: JSX.FocusEventHandler<HTMLTextAreaElement>, - onBlur: JSX.FocusEventHandler<HTMLTextAreaElement> -} + detached?: boolean; + state: AutoCompleteState; + setState: StateUpdater<AutoCompleteState>; + + onKeyUp: (ev: KeyboardEvent) => void; + onKeyDown: (ev: KeyboardEvent) => boolean; + onChange: (ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => void; + onClick: JSX.MouseEventHandler<HTMLButtonElement>; + onFocus: JSX.FocusEventHandler<HTMLTextAreaElement>; + onBlur: JSX.FocusEventHandler<HTMLTextAreaElement>; +}; -export function useAutoComplete(setValue: (v?: string) => void, searchClues?: SearchClues): AutoCompleteProps { - const [state, setState] = useState<AutoCompleteState>({ type: 'none' }); - const [focused, setFocused] = useState(false); - const client = useContext(AppContext); - - function findSearchString( - el: HTMLTextAreaElement - ): ["emoji" | "user" | "channel", string, number] | undefined { - if (el.selectionStart === el.selectionEnd) { - let cursor = el.selectionStart; - let content = el.value.slice(0, cursor); - - let valid = /\w/; - - let j = content.length - 1; - if (content[j] === '@') { - return [ - "user", - "", - j - ]; - } else if (content[j] === '#') { - return [ - "channel", - "", - j - ]; - } - - while (j >= 0 && valid.test(content[j])) { - j--; - } - - if (j === -1) return; - let current = content[j]; - - if (current === ":" || current === "@" || current === "#") { - let search = content.slice(j + 1, content.length); - if (search.length > 0) { - return [ - current === "#" ? "channel" : - current === ":" ? "emoji" : "user", - search.toLowerCase(), - j + 1 - ]; - } - } - } - } - - function onChange(ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) { - const el = ev.currentTarget; - - let result = findSearchString(el); - if (result) { - let [type, search] = result; - const regex = new RegExp(search, 'i'); - - if (type === "emoji") { - // ! FIXME: we should convert it to a Binary Search Tree and use that - let matches = Object.keys(emojiDictionary) - .filter((emoji: string) => emoji.match(regex)) - .splice(0, 5); - - if (matches.length > 0) { - let currentPosition = - state.type !== "none" - ? state.selected - : 0; - - setState({ - type: "emoji", - matches, - selected: Math.min(currentPosition, matches.length - 1), - within: false - }); - - return; - } - } - - if (type === "user" && searchClues?.users) { - let users: User[] = []; - switch (searchClues.users.type) { - case 'all': users = client.users.toArray(); break; - case 'channel': { - let channel = client.channels.get(searchClues.users.id); - switch (channel?.channel_type) { - case 'Group': - case 'DirectMessage': - users = client.users.mapKeys(channel.recipients) - .filter(x => typeof x !== 'undefined') as User[]; - break; - case 'TextChannel': - const server = channel.server; - users = client.servers.members.toArray() - .filter(x => x._id.substr(0, 26) === server) - .map(x => client.users.get(x._id.substr(26))) - .filter(x => typeof x !== 'undefined') as User[]; - break; - default: return; - } - } - } - - users = users.filter(x => x._id !== SYSTEM_USER_ID); - - let matches = (search.length > 0 ? users.filter(user => user.username.toLowerCase().match(regex)) : users) - .splice(0, 5) - .filter(x => typeof x !== "undefined"); - - if (matches.length > 0) { - let currentPosition = - state.type !== "none" - ? state.selected - : 0; - - setState({ - type: "user", - matches, - selected: Math.min(currentPosition, matches.length - 1), - within: false - }); - - return; - } - } - - if (type === 'channel' && searchClues?.channels) { - let channels = client.servers.get(searchClues.channels.server) - ?.channels - .map(x => client.channels.get(x)) - .filter(x => typeof x !== 'undefined') as Channels.TextChannel[]; - - let matches = (search.length > 0 ? channels.filter(channel => channel.name.toLowerCase().match(regex)) : channels) - .splice(0, 5) - .filter(x => typeof x !== "undefined"); - - if (matches.length > 0) { - let currentPosition = - state.type !== "none" - ? state.selected - : 0; - - setState({ - type: "channel", - matches, - selected: Math.min(currentPosition, matches.length - 1), - within: false - }); - - return; - } - } - } - - if (state.type !== "none") { - setState({ type: "none" }); - } - } - - function selectCurrent(el: HTMLTextAreaElement) { - if (state.type !== "none") { - let result = findSearchString(el); - if (result) { - let [_type, search, index] = result; - - let content = el.value.split(""); - if (state.type === "emoji") { - content.splice( - index, - search.length, - state.matches[state.selected], - ": " - ); - } else if (state.type === 'user') { - content.splice( - index - 1, - search.length + 1, - "<@", - state.matches[state.selected]._id, - "> " - ); - } else { - content.splice( - index - 1, - search.length + 1, - "<#", - state.matches[state.selected]._id, - "> " - ); - } - - setValue(content.join("")); - } - } - } - - function onClick(ev: JSX.TargetedMouseEvent<HTMLButtonElement>) { - ev.preventDefault(); - selectCurrent(document.querySelector("#message")!); - } - - function onKeyDown(e: KeyboardEvent) { - if (focused && state.type !== 'none') { - if (e.key === "ArrowUp") { - e.preventDefault(); - if (state.selected > 0) { - setState({ - ...state, - selected: state.selected - 1 - }); - } - - return true; - } - - if (e.key === "ArrowDown") { - e.preventDefault(); - if (state.selected < state.matches.length - 1) { - setState({ - ...state, - selected: state.selected + 1 - }); - } - - return true; - } - - if (e.key === "Enter" || e.key === "Tab") { - e.preventDefault(); - selectCurrent( - e.currentTarget as HTMLTextAreaElement - ); - - return true; - } - } - - return false; - } - - function onKeyUp(e: KeyboardEvent) { - if (e.currentTarget !== null) { - // @ts-expect-error - onChange(e); - } - } - - function onFocus(ev: JSX.TargetedFocusEvent<HTMLTextAreaElement>) { - setFocused(true); - onChange(ev); - } - - function onBlur() { - if (state.type !== 'none' && state.within) return; - setFocused(false); - } - - return { - state: focused ? state : { type: 'none' }, - setState, - - onClick, - onChange, - onKeyUp, - onKeyDown, - onFocus, - onBlur - } +export function useAutoComplete( + setValue: (v?: string) => void, + searchClues?: SearchClues, +): AutoCompleteProps { + const [state, setState] = useState<AutoCompleteState>({ type: "none" }); + const [focused, setFocused] = useState(false); + const client = useContext(AppContext); + + function findSearchString( + el: HTMLTextAreaElement, + ): ["emoji" | "user" | "channel", string, number] | undefined { + if (el.selectionStart === el.selectionEnd) { + let cursor = el.selectionStart; + let content = el.value.slice(0, cursor); + + let valid = /\w/; + + let j = content.length - 1; + if (content[j] === "@") { + return ["user", "", j]; + } else if (content[j] === "#") { + return ["channel", "", j]; + } + + while (j >= 0 && valid.test(content[j])) { + j--; + } + + if (j === -1) return; + let current = content[j]; + + if (current === ":" || current === "@" || current === "#") { + let search = content.slice(j + 1, content.length); + if (search.length > 0) { + return [ + current === "#" + ? "channel" + : current === ":" + ? "emoji" + : "user", + search.toLowerCase(), + j + 1, + ]; + } + } + } + } + + function onChange(ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) { + const el = ev.currentTarget; + + let result = findSearchString(el); + if (result) { + let [type, search] = result; + const regex = new RegExp(search, "i"); + + if (type === "emoji") { + // ! FIXME: we should convert it to a Binary Search Tree and use that + let matches = Object.keys(emojiDictionary) + .filter((emoji: string) => emoji.match(regex)) + .splice(0, 5); + + if (matches.length > 0) { + let currentPosition = + state.type !== "none" ? state.selected : 0; + + setState({ + type: "emoji", + matches, + selected: Math.min(currentPosition, matches.length - 1), + within: false, + }); + + return; + } + } + + if (type === "user" && searchClues?.users) { + let users: User[] = []; + switch (searchClues.users.type) { + case "all": + users = client.users.toArray(); + break; + case "channel": { + let channel = client.channels.get(searchClues.users.id); + switch (channel?.channel_type) { + case "Group": + case "DirectMessage": + users = client.users + .mapKeys(channel.recipients) + .filter( + (x) => typeof x !== "undefined", + ) as User[]; + break; + case "TextChannel": + const server = channel.server; + users = client.servers.members + .toArray() + .filter( + (x) => x._id.substr(0, 26) === server, + ) + .map((x) => + client.users.get(x._id.substr(26)), + ) + .filter( + (x) => typeof x !== "undefined", + ) as User[]; + break; + default: + return; + } + } + } + + users = users.filter((x) => x._id !== SYSTEM_USER_ID); + + let matches = ( + search.length > 0 + ? users.filter((user) => + user.username.toLowerCase().match(regex), + ) + : users + ) + .splice(0, 5) + .filter((x) => typeof x !== "undefined"); + + if (matches.length > 0) { + let currentPosition = + state.type !== "none" ? state.selected : 0; + + setState({ + type: "user", + matches, + selected: Math.min(currentPosition, matches.length - 1), + within: false, + }); + + return; + } + } + + if (type === "channel" && searchClues?.channels) { + let channels = client.servers + .get(searchClues.channels.server) + ?.channels.map((x) => client.channels.get(x)) + .filter( + (x) => typeof x !== "undefined", + ) as Channels.TextChannel[]; + + let matches = ( + search.length > 0 + ? channels.filter((channel) => + channel.name.toLowerCase().match(regex), + ) + : channels + ) + .splice(0, 5) + .filter((x) => typeof x !== "undefined"); + + if (matches.length > 0) { + let currentPosition = + state.type !== "none" ? state.selected : 0; + + setState({ + type: "channel", + matches, + selected: Math.min(currentPosition, matches.length - 1), + within: false, + }); + + return; + } + } + } + + if (state.type !== "none") { + setState({ type: "none" }); + } + } + + function selectCurrent(el: HTMLTextAreaElement) { + if (state.type !== "none") { + let result = findSearchString(el); + if (result) { + let [_type, search, index] = result; + + let content = el.value.split(""); + if (state.type === "emoji") { + content.splice( + index, + search.length, + state.matches[state.selected], + ": ", + ); + } else if (state.type === "user") { + content.splice( + index - 1, + search.length + 1, + "<@", + state.matches[state.selected]._id, + "> ", + ); + } else { + content.splice( + index - 1, + search.length + 1, + "<#", + state.matches[state.selected]._id, + "> ", + ); + } + + setValue(content.join("")); + } + } + } + + function onClick(ev: JSX.TargetedMouseEvent<HTMLButtonElement>) { + ev.preventDefault(); + selectCurrent(document.querySelector("#message")!); + } + + function onKeyDown(e: KeyboardEvent) { + if (focused && state.type !== "none") { + if (e.key === "ArrowUp") { + e.preventDefault(); + if (state.selected > 0) { + setState({ + ...state, + selected: state.selected - 1, + }); + } + + return true; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + if (state.selected < state.matches.length - 1) { + setState({ + ...state, + selected: state.selected + 1, + }); + } + + return true; + } + + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + selectCurrent(e.currentTarget as HTMLTextAreaElement); + + return true; + } + } + + return false; + } + + function onKeyUp(e: KeyboardEvent) { + if (e.currentTarget !== null) { + // @ts-expect-error + onChange(e); + } + } + + function onFocus(ev: JSX.TargetedFocusEvent<HTMLTextAreaElement>) { + setFocused(true); + onChange(ev); + } + + function onBlur() { + if (state.type !== "none" && state.within) return; + setFocused(false); + } + + return { + state: focused ? state : { type: "none" }, + setState, + + onClick, + onChange, + onKeyUp, + onKeyDown, + onFocus, + onBlur, + }; } const Base = styled.div<{ detached?: boolean }>` - position: relative; - - > div { - bottom: 0; - width: 100%; - position: absolute; - background: var(--primary-header); - } - - button { - gap: 8px; - margin: 4px; - padding: 6px; - border: none; - display: flex; - font-size: 1em; - cursor: pointer; - border-radius: 6px; - align-items: center; - flex-direction: row; - background: transparent; - color: var(--foreground); - width: calc(100% - 12px); - - span { - display: grid; - place-items: center; - } - - &.active { - background: var(--primary-background); - } - } - - ${ props => props.detached && css` - bottom: 8px; - - > div { - border-radius: 4px; - } - ` } + position: relative; + + > div { + bottom: 0; + width: 100%; + position: absolute; + background: var(--primary-header); + } + + button { + gap: 8px; + margin: 4px; + padding: 6px; + border: none; + display: flex; + font-size: 1em; + cursor: pointer; + border-radius: 6px; + align-items: center; + flex-direction: row; + background: transparent; + color: var(--foreground); + width: calc(100% - 12px); + + span { + display: grid; + place-items: center; + } + + &.active { + background: var(--primary-background); + } + } + + ${(props) => + props.detached && + css` + bottom: 8px; + + > div { + border-radius: 4px; + } + `} `; -export default function AutoComplete({ detached, state, setState, onClick }: Pick<AutoCompleteProps, 'detached' | 'state' | 'setState' | 'onClick'>) { - return ( - <Base detached={detached}> - <div> - {state.type === "emoji" && - state.matches.map((match, i) => ( - <button - className={i === state.selected ? "active" : ''} - onMouseEnter={() => - (i !== state.selected || - !state.within) && - setState({ - ...state, - selected: i, - within: true - }) - } - onMouseLeave={() => - state.within && - setState({ - ...state, - within: false - }) - } - onClick={onClick}> - <Emoji emoji={(emojiDictionary as Record<string, string>)[match]} size={20} /> - :{match}: - </button> - ))} - {state.type === "user" && - state.matches.map((match, i) => ( - <button - className={i === state.selected ? "active" : ''} - onMouseEnter={() => - (i !== state.selected || - !state.within) && - setState({ - ...state, - selected: i, - within: true - }) - } - onMouseLeave={() => - state.within && - setState({ - ...state, - within: false - }) - } - onClick={onClick}> - <UserIcon - size={24} - target={match} - status={true} /> - {match.username} - </button> - ))} - {state.type === "channel" && - state.matches.map((match, i) => ( - <button - className={i === state.selected ? "active" : ''} - onMouseEnter={() => - (i !== state.selected || - !state.within) && - setState({ - ...state, - selected: i, - within: true - }) - } - onMouseLeave={() => - state.within && - setState({ - ...state, - within: false - }) - } - onClick={onClick}> - <ChannelIcon - size={24} - target={match} /> - {match.name} - </button> - ))} - </div> - </Base> - ) +export default function AutoComplete({ + detached, + state, + setState, + onClick, +}: Pick<AutoCompleteProps, "detached" | "state" | "setState" | "onClick">) { + return ( + <Base detached={detached}> + <div> + {state.type === "emoji" && + state.matches.map((match, i) => ( + <button + className={i === state.selected ? "active" : ""} + onMouseEnter={() => + (i !== state.selected || !state.within) && + setState({ + ...state, + selected: i, + within: true, + }) + } + onMouseLeave={() => + state.within && + setState({ + ...state, + within: false, + }) + } + onClick={onClick}> + <Emoji + emoji={ + (emojiDictionary as Record<string, string>)[ + match + ] + } + size={20} + /> + :{match}: + </button> + ))} + {state.type === "user" && + state.matches.map((match, i) => ( + <button + className={i === state.selected ? "active" : ""} + onMouseEnter={() => + (i !== state.selected || !state.within) && + setState({ + ...state, + selected: i, + within: true, + }) + } + onMouseLeave={() => + state.within && + setState({ + ...state, + within: false, + }) + } + onClick={onClick}> + <UserIcon size={24} target={match} status={true} /> + {match.username} + </button> + ))} + {state.type === "channel" && + state.matches.map((match, i) => ( + <button + className={i === state.selected ? "active" : ""} + onMouseEnter={() => + (i !== state.selected || !state.within) && + setState({ + ...state, + selected: i, + within: true, + }) + } + onMouseLeave={() => + state.within && + setState({ + ...state, + within: false, + }) + } + onClick={onClick}> + <ChannelIcon size={24} target={match} /> + {match.name} + </button> + ))} + </div> + </Base> + ); } diff --git a/src/components/common/ChannelIcon.tsx b/src/components/common/ChannelIcon.tsx index c0d596ed52a9ed749db85725cb966cd8994cd1a6..e3dd136bba719bf9ddb49844dad7f2746be99f5a 100644 --- a/src/components/common/ChannelIcon.tsx +++ b/src/components/common/ChannelIcon.tsx @@ -1,43 +1,65 @@ -import { useContext } from "preact/hooks"; -import { Channels } from "revolt.js/dist/api/objects"; import { Hash, VolumeFull } from "@styled-icons/boxicons-regular"; -import { ImageIconBase, IconBaseProps } from "./IconBase"; +import { Channels } from "revolt.js/dist/api/objects"; + +import { useContext } from "preact/hooks"; + import { AppContext } from "../../context/revoltjs/RevoltClient"; -interface Props extends IconBaseProps<Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel> { - isServerChannel?: boolean; +import { ImageIconBase, IconBaseProps } from "./IconBase"; +import fallback from "./assets/group.png"; + +interface Props + extends IconBaseProps< + Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel + > { + isServerChannel?: boolean; } -import fallback from './assets/group.png'; - -export default function ChannelIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>) { - const client = useContext(AppContext); - - const { size, target, attachment, isServerChannel: server, animate, children, as, ...imgProps } = props; - const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate); - const isServerChannel = server || (target && (target.channel_type === 'TextChannel' || target.channel_type === 'VoiceChannel')); - - if (typeof iconURL === 'undefined') { - if (isServerChannel) { - if (target?.channel_type === 'VoiceChannel') { - return ( - <VolumeFull size={size} /> - ) - } else { - return ( - <Hash size={size} /> - ) - } - } - } - - return ( - // ! fixme: replace fallback with <picture /> + <source /> - <ImageIconBase {...imgProps} - width={size} - height={size} - aria-hidden="true" - square={isServerChannel} - src={iconURL ?? fallback} /> - ); +export default function ChannelIcon( + props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>, +) { + const client = useContext(AppContext); + + const { + size, + target, + attachment, + isServerChannel: server, + animate, + children, + as, + ...imgProps + } = props; + const iconURL = client.generateFileURL( + target?.icon ?? attachment, + { max_side: 256 }, + animate, + ); + const isServerChannel = + server || + (target && + (target.channel_type === "TextChannel" || + target.channel_type === "VoiceChannel")); + + if (typeof iconURL === "undefined") { + if (isServerChannel) { + if (target?.channel_type === "VoiceChannel") { + return <VolumeFull size={size} />; + } else { + return <Hash size={size} />; + } + } + } + + return ( + // ! fixme: replace fallback with <picture /> + <source /> + <ImageIconBase + {...imgProps} + width={size} + height={size} + aria-hidden="true" + square={isServerChannel} + src={iconURL ?? fallback} + /> + ); } diff --git a/src/components/common/CollapsibleSection.tsx b/src/components/common/CollapsibleSection.tsx index 3b6409fb9a32b916f697110693ea57b5890e9302..3bd20db000806983ce48229bf07ab43e1fd23488 100644 --- a/src/components/common/CollapsibleSection.tsx +++ b/src/components/common/CollapsibleSection.tsx @@ -1,50 +1,59 @@ -import Details from "../ui/Details"; +import { ChevronDown } from "@styled-icons/boxicons-regular"; + import { State, store } from "../../redux"; import { Action } from "../../redux/reducers"; + +import Details from "../ui/Details"; + import { Children } from "../../types/Preact"; -import { ChevronDown } from "@styled-icons/boxicons-regular"; interface Props { - id: string; - defaultValue: boolean; + id: string; + defaultValue: boolean; - sticky?: boolean; - large?: boolean; + sticky?: boolean; + large?: boolean; - summary: Children; - children: Children; + summary: Children; + children: Children; } -export default function CollapsibleSection({ id, defaultValue, summary, children, ...detailsProps }: Props) { - const state: State = store.getState(); - - function setState(state: boolean) { - if (state === defaultValue) { - store.dispatch({ - type: 'SECTION_TOGGLE_UNSET', - id - } as Action); - } else { - store.dispatch({ - type: 'SECTION_TOGGLE_SET', - id, - state - } as Action); - } - } - - return ( - <Details - open={state.sectionToggle[id] ?? defaultValue} - onToggle={e => setState(e.currentTarget.open)} - {...detailsProps}> - <summary> - <div class="padding"> - <ChevronDown size={20} /> - { summary } - </div> - </summary> - { children } - </Details> - ) +export default function CollapsibleSection({ + id, + defaultValue, + summary, + children, + ...detailsProps +}: Props) { + const state: State = store.getState(); + + function setState(state: boolean) { + if (state === defaultValue) { + store.dispatch({ + type: "SECTION_TOGGLE_UNSET", + id, + } as Action); + } else { + store.dispatch({ + type: "SECTION_TOGGLE_SET", + id, + state, + } as Action); + } + } + + return ( + <Details + open={state.sectionToggle[id] ?? defaultValue} + onToggle={(e) => setState(e.currentTarget.open)} + {...detailsProps}> + <summary> + <div class="padding"> + <ChevronDown size={20} /> + {summary} + </div> + </summary> + {children} + </Details> + ); } diff --git a/src/components/common/Emoji.tsx b/src/components/common/Emoji.tsx index 791a0036dcbb0f0a2bc002e11f9d57e61582d0cd..f27be2f5d223333de662c755ab020dc3360e28c2 100644 --- a/src/components/common/Emoji.tsx +++ b/src/components/common/Emoji.tsx @@ -1,62 +1,72 @@ -import { EmojiPacks } from '../../redux/reducers/settings'; +import { EmojiPacks } from "../../redux/reducers/settings"; -var EMOJI_PACK = 'mutant'; +var EMOJI_PACK = "mutant"; const REVISION = 3; export function setEmojiPack(pack: EmojiPacks) { - EMOJI_PACK = pack; + EMOJI_PACK = pack; } // Originally taken from Twemoji source code, // re-written by bree to be more readable. function codePoints(rune: string) { - const pairs = []; - let low = 0; - let i = 0; - - while (i < rune.length) { - const charCode = rune.charCodeAt(i++); - if (low) { - pairs.push(0x10000 + ((low - 0xd800) << 10) + (charCode - 0xdc00)); - low = 0; - } else if (0xd800 <= charCode && charCode <= 0xdbff) { - low = charCode; - } else { - pairs.push(charCode); - } - } - - return pairs; + const pairs = []; + let low = 0; + let i = 0; + + while (i < rune.length) { + const charCode = rune.charCodeAt(i++); + if (low) { + pairs.push(0x10000 + ((low - 0xd800) << 10) + (charCode - 0xdc00)); + low = 0; + } else if (0xd800 <= charCode && charCode <= 0xdbff) { + low = charCode; + } else { + pairs.push(charCode); + } + } + + return pairs; } // Taken from Twemoji source code. // scripts/build.js#344 // grabTheRightIcon(rawText); const UFE0Fg = /\uFE0F/g; -const U200D = String.fromCharCode(0x200D); +const U200D = String.fromCharCode(0x200d); function toCodePoint(rune: string) { - return codePoints(rune.indexOf(U200D) < 0 ? rune.replace(UFE0Fg, '') : rune) - .map((val) => val.toString(16)) - .join("-") + return codePoints(rune.indexOf(U200D) < 0 ? rune.replace(UFE0Fg, "") : rune) + .map((val) => val.toString(16)) + .join("-"); } function parseEmoji(emoji: string) { - let codepoint = toCodePoint(emoji); - return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`; + let codepoint = toCodePoint(emoji); + return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`; } -export default function Emoji({ emoji, size }: { emoji: string, size?: number }) { - return ( - <img - alt={emoji} - className="emoji" - draggable={false} - src={parseEmoji(emoji)} - style={size ? { width: `${size}px`, height: `${size}px` } : undefined} - /> - ) +export default function Emoji({ + emoji, + size, +}: { + emoji: string; + size?: number; +}) { + return ( + <img + alt={emoji} + className="emoji" + draggable={false} + src={parseEmoji(emoji)} + style={ + size ? { width: `${size}px`, height: `${size}px` } : undefined + } + /> + ); } export function generateEmoji(emoji: string) { - return `<img class="emoji" draggable="false" alt="${emoji}" src="${parseEmoji(emoji)}" />`; + return `<img class="emoji" draggable="false" alt="${emoji}" src="${parseEmoji( + emoji, + )}" />`; } diff --git a/src/components/common/IconBase.tsx b/src/components/common/IconBase.tsx index acb5387ea9216d2af1346c8a955ab90fe2641662..56640d90edcaa277fdef7c776a37f005a60f8254 100644 --- a/src/components/common/IconBase.tsx +++ b/src/components/common/IconBase.tsx @@ -2,36 +2,40 @@ import { Attachment } from "revolt.js/dist/api/objects"; import styled, { css } from "styled-components"; export interface IconBaseProps<T> { - target?: T; - attachment?: Attachment; + target?: T; + attachment?: Attachment; - size: number; - animate?: boolean; + size: number; + animate?: boolean; } interface IconModifiers { - square?: boolean + square?: boolean; } export default styled.svg<IconModifiers>` - flex-shrink: 0; - - img { - width: 100%; - height: 100%; - object-fit: cover; - - ${ props => !props.square && css` - border-radius: 50%; - ` } - } + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + + ${(props) => + !props.square && + css` + border-radius: 50%; + `} + } `; export const ImageIconBase = styled.img<IconModifiers>` - flex-shrink: 0; - object-fit: cover; - - ${ props => !props.square && css` - border-radius: 50%; - ` } + flex-shrink: 0; + object-fit: cover; + + ${(props) => + !props.square && + css` + border-radius: 50%; + `} `; diff --git a/src/components/common/LocaleSelector.tsx b/src/components/common/LocaleSelector.tsx index 7f91b1c7a3063eec5f63dd373c797dee3819af81..c214e6bf9a54b0914eae48c7c737aec0dae3ca2b 100644 --- a/src/components/common/LocaleSelector.tsx +++ b/src/components/common/LocaleSelector.tsx @@ -1,40 +1,38 @@ -import ComboBox from "../ui/ComboBox"; import { dispatch } from "../../redux"; import { connectState } from "../../redux/connector"; -import { Language, LanguageEntry, Languages } from "../../context/Locale"; + +import { Language, Languages } from "../../context/Locale"; + +import ComboBox from "../ui/ComboBox"; type Props = { - locale: string; + locale: string; }; export function LocaleSelector(props: Props) { - return ( - <ComboBox - value={props.locale} - onChange={e => - dispatch({ - type: "SET_LOCALE", - locale: e.currentTarget.value as Language - }) - } - > - {Object.keys(Languages).map(x => { - const l = Languages[x as keyof typeof Languages]; - return ( - <option value={x}> - {l.emoji} {l.display} - </option> - ); - })} - </ComboBox> - ); + return ( + <ComboBox + value={props.locale} + onChange={(e) => + dispatch({ + type: "SET_LOCALE", + locale: e.currentTarget.value as Language, + }) + }> + {Object.keys(Languages).map((x) => { + const l = Languages[x as keyof typeof Languages]; + return ( + <option value={x}> + {l.emoji} {l.display} + </option> + ); + })} + </ComboBox> + ); } -export default connectState( - LocaleSelector, - state => { - return { - locale: state.locale - }; - } -); +export default connectState(LocaleSelector, (state) => { + return { + locale: state.locale, + }; +}); diff --git a/src/components/common/ServerHeader.tsx b/src/components/common/ServerHeader.tsx index 1218cdd1347755bdcb474dd928e37fbd6f599892..46e6cd5bad610b1860d902e2dedaf6708611b43d 100644 --- a/src/components/common/ServerHeader.tsx +++ b/src/components/common/ServerHeader.tsx @@ -1,40 +1,49 @@ -import Header from "../ui/Header"; -import styled from "styled-components"; -import { Link } from "react-router-dom"; -import IconButton from "../ui/IconButton"; import { Cog } from "@styled-icons/boxicons-solid"; +import { Link } from "react-router-dom"; import { Server } from "revolt.js/dist/api/objects"; import { ServerPermission } from "revolt.js/dist/api/permissions"; +import styled from "styled-components"; + import { HookContext, useServerPermission } from "../../context/revoltjs/hooks"; +import Header from "../ui/Header"; +import IconButton from "../ui/IconButton"; + interface Props { - server: Server, - ctx: HookContext + server: Server; + ctx: HookContext; } const ServerName = styled.div` - flex-grow: 1; + flex-grow: 1; `; export default function ServerHeader({ server, ctx }: Props) { - const permissions = useServerPermission(server._id, ctx); - const bannerURL = ctx.client.servers.getBannerURL(server._id, { width: 480 }, true); + const permissions = useServerPermission(server._id, ctx); + const bannerURL = ctx.client.servers.getBannerURL( + server._id, + { width: 480 }, + true, + ); - return ( - <Header borders - placement="secondary" - background={typeof bannerURL !== 'undefined'} - style={{ background: bannerURL ? `url('${bannerURL}')` : undefined }}> - <ServerName> - { server.name } - </ServerName> - { (permissions & ServerPermission.ManageServer) > 0 && <div className="actions"> - <Link to={`/server/${server._id}/settings`}> - <IconButton> - <Cog size={24} /> - </IconButton> - </Link> - </div> } - </Header> - ) + return ( + <Header + borders + placement="secondary" + background={typeof bannerURL !== "undefined"} + style={{ + background: bannerURL ? `url('${bannerURL}')` : undefined, + }}> + <ServerName>{server.name}</ServerName> + {(permissions & ServerPermission.ManageServer) > 0 && ( + <div className="actions"> + <Link to={`/server/${server._id}/settings`}> + <IconButton> + <Cog size={24} /> + </IconButton> + </Link> + </div> + )} + </Header> + ); } diff --git a/src/components/common/ServerIcon.tsx b/src/components/common/ServerIcon.tsx index e3db726322a2054517af8653ab9b920dfa3c19ff..061a18d61ab0921860ebd394392f9aa674880603 100644 --- a/src/components/common/ServerIcon.tsx +++ b/src/components/common/ServerIcon.tsx @@ -1,47 +1,68 @@ +import { Server } from "revolt.js/dist/api/objects"; import styled from "styled-components"; + import { useContext } from "preact/hooks"; -import { Server } from "revolt.js/dist/api/objects"; -import { IconBaseProps, ImageIconBase } from "./IconBase"; + import { AppContext } from "../../context/revoltjs/RevoltClient"; +import { IconBaseProps, ImageIconBase } from "./IconBase"; + interface Props extends IconBaseProps<Server> { - server_name?: string; + server_name?: string; } const ServerText = styled.div` - display: grid; - padding: .2em; - overflow: hidden; - border-radius: 50%; - place-items: center; - color: var(--foreground); - background: var(--primary-background); + display: grid; + padding: 0.2em; + overflow: hidden; + border-radius: 50%; + place-items: center; + color: var(--foreground); + background: var(--primary-background); `; -const fallback = '/assets/group.png'; -export default function ServerIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>) { - const client = useContext(AppContext); - - const { target, attachment, size, animate, server_name, children, as, ...imgProps } = props; - const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate); - - if (typeof iconURL === 'undefined') { - const name = target?.name ?? server_name ?? ''; - - return ( - <ServerText style={{ width: size, height: size }}> - { name.split(' ') - .map(x => x[0]) - .filter(x => typeof x !== 'undefined') } - </ServerText> - ) - } - - return ( - <ImageIconBase {...imgProps} - width={size} - height={size} - aria-hidden="true" - src={iconURL} /> - ); +const fallback = "/assets/group.png"; +export default function ServerIcon( + props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>, +) { + const client = useContext(AppContext); + + const { + target, + attachment, + size, + animate, + server_name, + children, + as, + ...imgProps + } = props; + const iconURL = client.generateFileURL( + target?.icon ?? attachment, + { max_side: 256 }, + animate, + ); + + if (typeof iconURL === "undefined") { + const name = target?.name ?? server_name ?? ""; + + return ( + <ServerText style={{ width: size, height: size }}> + {name + .split(" ") + .map((x) => x[0]) + .filter((x) => typeof x !== "undefined")} + </ServerText> + ); + } + + return ( + <ImageIconBase + {...imgProps} + width={size} + height={size} + aria-hidden="true" + src={iconURL} + /> + ); } diff --git a/src/components/common/Tooltip.tsx b/src/components/common/Tooltip.tsx index 7a9957f4cc6355bf0d4a34d1f7ab3f4f8e35c6c3..2dbfa24f76279068928c2cd9f90b0986541cff28 100644 --- a/src/components/common/Tooltip.tsx +++ b/src/components/common/Tooltip.tsx @@ -1,49 +1,60 @@ -import { Text } from "preact-i18n"; +import Tippy, { TippyProps } from "@tippyjs/react"; import styled from "styled-components"; + +import { Text } from "preact-i18n"; + import { Children } from "../../types/Preact"; -import Tippy, { TippyProps } from '@tippyjs/react'; -type Props = Omit<TippyProps, 'children'> & { - children: Children; - content: Children; -} +type Props = Omit<TippyProps, "children"> & { + children: Children; + content: Children; +}; export default function Tooltip(props: Props) { - const { children, content, ...tippyProps } = props; + const { children, content, ...tippyProps } = props; - return ( - <Tippy content={content} {...tippyProps}> - {/* + return ( + <Tippy content={content} {...tippyProps}> + {/* // @ts-expect-error */} - <div>{ children }</div> - </Tippy> - ); + <div>{children}</div> + </Tippy> + ); } const PermissionTooltipBase = styled.div` - display: flex; - align-items: center; - flex-direction: column; - - span { - font-weight: 700; - text-transform: uppercase; - color: var(--secondary-foreground); - font-size: 11px; - } - - code { - font-family: var(--monoscape-font); - } + display: flex; + align-items: center; + flex-direction: column; + + span { + font-weight: 700; + text-transform: uppercase; + color: var(--secondary-foreground); + font-size: 11px; + } + + code { + font-family: var(--monoscape-font); + } `; -export function PermissionTooltip(props: Omit<Props, 'content'> & { permission: string }) { - const { permission, ...tooltipProps } = props; +export function PermissionTooltip( + props: Omit<Props, "content"> & { permission: string }, +) { + const { permission, ...tooltipProps } = props; - return ( - <Tooltip content={<PermissionTooltipBase> - <span><Text id="app.permissions.required" /></span> - <code>{ permission }</code> - </PermissionTooltipBase>} {...tooltipProps} /> - ) + return ( + <Tooltip + content={ + <PermissionTooltipBase> + <span> + <Text id="app.permissions.required" /> + </span> + <code>{permission}</code> + </PermissionTooltipBase> + } + {...tooltipProps} + /> + ); } diff --git a/src/components/common/UpdateIndicator.tsx b/src/components/common/UpdateIndicator.tsx index 937d6522a3004400bbc62ad67cdd693a4eb993f7..d818c2ee075da6f0312ad28daa39f0eb14ec52a1 100644 --- a/src/components/common/UpdateIndicator.tsx +++ b/src/components/common/UpdateIndicator.tsx @@ -1,26 +1,31 @@ -import { updateSW } from "../../main"; -import IconButton from "../ui/IconButton"; -import { ThemeContext } from "../../context/Theme"; import { Download } from "@styled-icons/boxicons-regular"; -import { internalSubscribe } from "../../lib/eventEmitter"; + import { useContext, useEffect, useState } from "preact/hooks"; +import { internalSubscribe } from "../../lib/eventEmitter"; + +import { ThemeContext } from "../../context/Theme"; + +import IconButton from "../ui/IconButton"; + +import { updateSW } from "../../main"; + var pendingUpdate = false; -internalSubscribe('PWA', 'update', () => pendingUpdate = true); +internalSubscribe("PWA", "update", () => (pendingUpdate = true)); export default function UpdateIndicator() { - const [ pending, setPending ] = useState(pendingUpdate); + const [pending, setPending] = useState(pendingUpdate); - useEffect(() => { - return internalSubscribe('PWA', 'update', () => setPending(true)); - }); + useEffect(() => { + return internalSubscribe("PWA", "update", () => setPending(true)); + }); - if (!pending) return null; - const theme = useContext(ThemeContext); + if (!pending) return null; + const theme = useContext(ThemeContext); - return ( - <IconButton onClick={() => updateSW(true)}> - <Download size={22} color={theme.success} /> - </IconButton> - ) + return ( + <IconButton onClick={() => updateSW(true)}> + <Download size={22} color={theme.success} /> + </IconButton> + ); } diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx index f00aad634dec4f7e7b7151039dd636dca0c4b4c2..1dda28d9a683babb4a5bd124471a6458a8da8b02 100644 --- a/src/components/common/messaging/Message.tsx +++ b/src/components/common/messaging/Message.tsx @@ -1,78 +1,133 @@ -import Embed from "./embed/Embed"; -import UserIcon from "../user/UserIcon"; -import { Username } from "../user/UserShort"; -import Markdown from "../../markdown/Markdown"; -import { Children } from "../../../types/Preact"; -import Attachment from "./attachments/Attachment"; import { attachContextMenu } from "preact-context-menu"; -import { useUser } from "../../../context/revoltjs/hooks"; +import { memo } from "preact/compat"; +import { useContext } from "preact/hooks"; + import { QueuedMessage } from "../../../redux/reducers/queue"; + +import { useIntermediate } from "../../../context/intermediate/Intermediate"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { useUser } from "../../../context/revoltjs/hooks"; import { MessageObject } from "../../../context/revoltjs/util"; -import MessageBase, { MessageContent, MessageDetail, MessageInfo } from "./MessageBase"; + import Overline from "../../ui/Overline"; -import { useContext } from "preact/hooks"; -import { AppContext } from "../../../context/revoltjs/RevoltClient"; -import { memo } from "preact/compat"; + +import { Children } from "../../../types/Preact"; +import Markdown from "../../markdown/Markdown"; +import UserIcon from "../user/UserIcon"; +import { Username } from "../user/UserShort"; +import MessageBase, { + MessageContent, + MessageDetail, + MessageInfo, +} from "./MessageBase"; +import Attachment from "./attachments/Attachment"; import { MessageReply } from "./attachments/MessageReply"; -import { useIntermediate } from "../../../context/intermediate/Intermediate"; +import Embed from "./embed/Embed"; interface Props { - attachContext?: boolean - queued?: QueuedMessage - message: MessageObject - contrast?: boolean - content?: Children - head?: boolean + attachContext?: boolean; + queued?: QueuedMessage; + message: MessageObject; + contrast?: boolean; + content?: Children; + head?: boolean; } -function Message({ attachContext, message, contrast, content: replacement, head: preferHead, queued }: Props) { - // TODO: Can improve re-renders here by providing a list - // TODO: of dependencies. We only need to update on u/avatar. - const user = useUser(message.author); - const client = useContext(AppContext); - const { openScreen } = useIntermediate(); +function Message({ + attachContext, + message, + contrast, + content: replacement, + head: preferHead, + queued, +}: Props) { + // TODO: Can improve re-renders here by providing a list + // TODO: of dependencies. We only need to update on u/avatar. + const user = useUser(message.author); + const client = useContext(AppContext); + const { openScreen } = useIntermediate(); - const content = message.content as string; - const head = preferHead || (message.replies && message.replies.length > 0); + const content = message.content as string; + const head = preferHead || (message.replies && message.replies.length > 0); - // ! FIXME: tell fatal to make this type generic - // bree: Fatal please... - const userContext = attachContext - ? attachContextMenu('Menu', { user: message.author, contextualChannel: message.channel }) as any - : undefined; + // ! FIXME: tell fatal to make this type generic + // bree: Fatal please... + const userContext = attachContext + ? (attachContextMenu("Menu", { + user: message.author, + contextualChannel: message.channel, + }) as any) + : undefined; - const openProfile = () => openScreen({ id: 'profile', user_id: message.author }); + const openProfile = () => + openScreen({ id: "profile", user_id: message.author }); - return ( - <div id={message._id}> - { message.replies?.map((message_id, index) => <MessageReply index={index} id={message_id} channel={message.channel} />) } - <MessageBase - head={head && !(message.replies && message.replies.length > 0)} - contrast={contrast} - sending={typeof queued !== 'undefined'} - mention={message.mentions?.includes(client.user!._id)} - failed={typeof queued?.error !== 'undefined'} - onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel, queued }) : undefined}> - <MessageInfo> - { head ? - <UserIcon target={user} size={36} onContextMenu={userContext} onClick={openProfile} /> : - <MessageDetail message={message} position="left" /> } - </MessageInfo> - <MessageContent> - { head && <span className="detail"> - <Username className="author" user={user} onContextMenu={userContext} onClick={openProfile} /> - <MessageDetail message={message} position="top" /> - </span> } - { replacement ?? <Markdown content={content} /> } - { queued?.error && <Overline type="error" error={queued.error} /> } - { message.attachments?.map((attachment, index) => - <Attachment key={index} attachment={attachment} hasContent={ index > 0 || content.length > 0 } />) } - { message.embeds?.map((embed, index) => - <Embed key={index} embed={embed} />) } - </MessageContent> - </MessageBase> - </div> - ) + return ( + <div id={message._id}> + {message.replies?.map((message_id, index) => ( + <MessageReply + index={index} + id={message_id} + channel={message.channel} + /> + ))} + <MessageBase + head={head && !(message.replies && message.replies.length > 0)} + contrast={contrast} + sending={typeof queued !== "undefined"} + mention={message.mentions?.includes(client.user!._id)} + failed={typeof queued?.error !== "undefined"} + onContextMenu={ + attachContext + ? attachContextMenu("Menu", { + message, + contextualChannel: message.channel, + queued, + }) + : undefined + }> + <MessageInfo> + {head ? ( + <UserIcon + target={user} + size={36} + onContextMenu={userContext} + onClick={openProfile} + /> + ) : ( + <MessageDetail message={message} position="left" /> + )} + </MessageInfo> + <MessageContent> + {head && ( + <span className="detail"> + <Username + className="author" + user={user} + onContextMenu={userContext} + onClick={openProfile} + /> + <MessageDetail message={message} position="top" /> + </span> + )} + {replacement ?? <Markdown content={content} />} + {queued?.error && ( + <Overline type="error" error={queued.error} /> + )} + {message.attachments?.map((attachment, index) => ( + <Attachment + key={index} + attachment={attachment} + hasContent={index > 0 || content.length > 0} + /> + ))} + {message.embeds?.map((embed, index) => ( + <Embed key={index} embed={embed} /> + ))} + </MessageContent> + </MessageBase> + </div> + ); } export default memo(Message); diff --git a/src/components/common/messaging/MessageBase.tsx b/src/components/common/messaging/MessageBase.tsx index 6efa2284ef5da40c60de4394872de5e870899b22..ee9fa9466de8c37cc044471916fe2a18eea632af 100644 --- a/src/components/common/messaging/MessageBase.tsx +++ b/src/components/common/messaging/MessageBase.tsx @@ -1,189 +1,212 @@ import dayjs from "dayjs"; -import Tooltip from "../Tooltip"; +import styled, { css } from "styled-components"; import { decodeTime } from "ulid"; + import { Text } from "preact-i18n"; -import styled, { css } from "styled-components"; + import { MessageObject } from "../../../context/revoltjs/util"; +import Tooltip from "../Tooltip"; + export interface BaseMessageProps { - head?: boolean, - failed?: boolean, - mention?: boolean, - blocked?: boolean, - sending?: boolean, - contrast?: boolean + head?: boolean; + failed?: boolean; + mention?: boolean; + blocked?: boolean; + sending?: boolean; + contrast?: boolean; } export default styled.div<BaseMessageProps>` - display: flex; - overflow-x: none; - padding: .125rem; - flex-direction: row; - padding-right: 16px; - - ${ props => props.contrast && css` - padding: .3rem; - border-radius: 4px; - background: var(--hover); - ` } - - ${ props => props.head && css` - margin-top: 12px; - ` } - - ${ props => props.mention && css` - background: var(--mention); - ` } - - ${ props => props.blocked && css` - filter: blur(4px); - transition: 0.2s ease filter; - - &:hover { - filter: none; - } - ` } - - ${ props => props.sending && css` - opacity: 0.8; - color: var(--tertiary-foreground); - ` } - - ${ props => props.failed && css` - color: var(--error); - ` } + display: flex; + overflow-x: none; + padding: 0.125rem; + flex-direction: row; + padding-right: 16px; + + ${(props) => + props.contrast && + css` + padding: 0.3rem; + border-radius: 4px; + background: var(--hover); + `} + + ${(props) => + props.head && + css` + margin-top: 12px; + `} + + ${(props) => + props.mention && + css` + background: var(--mention); + `} + + ${(props) => + props.blocked && + css` + filter: blur(4px); + transition: 0.2s ease filter; + + &:hover { + filter: none; + } + `} + + ${(props) => + props.sending && + css` + opacity: 0.8; + color: var(--tertiary-foreground); + `} + + ${(props) => + props.failed && + css` + color: var(--error); + `} .detail { - gap: 8px; - display: flex; - align-items: center; - } - - .author { - cursor: pointer; - font-weight: 600 !important; - - &:hover { - text-decoration: underline; - } - } - - .copy { - display: block; - overflow: hidden; - } - - &:hover { - background: var(--hover); - - time { - opacity: 1; - } - } + gap: 8px; + display: flex; + align-items: center; + } + + .author { + cursor: pointer; + font-weight: 600 !important; + + &:hover { + text-decoration: underline; + } + } + + .copy { + display: block; + overflow: hidden; + } + + &:hover { + background: var(--hover); + + time { + opacity: 1; + } + } `; export const MessageInfo = styled.div` - width: 62px; - display: flex; - flex-shrink: 0; - padding-top: 2px; - flex-direction: row; - justify-content: center; - - .copyBracket { - opacity: 0; - position: absolute; - } - - .copyTime { - opacity: 0; - position: absolute; - } - - svg { - user-select: none; - cursor: pointer; - - &:active { - transform: translateY(1px); - } - } - - time { - opacity: 0; - } - - time, .edited { - margin-top: 1px; - cursor: default; - display: inline; - font-size: 10px; - color: var(--tertiary-foreground); - } - - time, .edited > div { - &::selection { - background-color: transparent; - color: var(--tertiary-foreground); - } - } + width: 62px; + display: flex; + flex-shrink: 0; + padding-top: 2px; + flex-direction: row; + justify-content: center; + + .copyBracket { + opacity: 0; + position: absolute; + } + + .copyTime { + opacity: 0; + position: absolute; + } + + svg { + user-select: none; + cursor: pointer; + + &:active { + transform: translateY(1px); + } + } + + time { + opacity: 0; + } + + time, + .edited { + margin-top: 1px; + cursor: default; + display: inline; + font-size: 10px; + color: var(--tertiary-foreground); + } + + time, + .edited > div { + &::selection { + background-color: transparent; + color: var(--tertiary-foreground); + } + } `; export const MessageContent = styled.div` - min-width: 0; - flex-grow: 1; - display: flex; - // overflow: hidden; - font-size: .875rem; - flex-direction: column; - justify-content: center; + min-width: 0; + flex-grow: 1; + display: flex; + // overflow: hidden; + font-size: 0.875rem; + flex-direction: column; + justify-content: center; `; export const DetailBase = styled.div` - gap: 4px; - font-size: 10px; - display: inline-flex; - color: var(--tertiary-foreground); + gap: 4px; + font-size: 10px; + display: inline-flex; + color: var(--tertiary-foreground); `; -export function MessageDetail({ message, position }: { message: MessageObject, position: 'left' | 'top' }) { - if (position === 'left') { - if (message.edited) { - return ( - <> - <time className="copyTime"> - <i className="copyBracket">[</i> - {dayjs(decodeTime(message._id)).format("H:mm")} - <i className="copyBracket">]</i> - </time> - <span className="edited"> - <Tooltip content={dayjs(message.edited).format("LLLL")}> - <Text id="app.main.channel.edited" /> - </Tooltip> - </span> - </> - ) - } else { - return ( - <> - <time> - <i className="copyBracket">[</i> - { dayjs(decodeTime(message._id)).format("H:mm") } - <i className="copyBracket">]</i> - </time> - </> - ) - } - } - - return ( - <DetailBase> - <time> - {dayjs(decodeTime(message._id)).calendar()} - </time> - { message.edited && <Tooltip content={dayjs(message.edited).format("LLLL")}> - <Text id="app.main.channel.edited" /> - </Tooltip> } - </DetailBase> - ) +export function MessageDetail({ + message, + position, +}: { + message: MessageObject; + position: "left" | "top"; +}) { + if (position === "left") { + if (message.edited) { + return ( + <> + <time className="copyTime"> + <i className="copyBracket">[</i> + {dayjs(decodeTime(message._id)).format("H:mm")} + <i className="copyBracket">]</i> + </time> + <span className="edited"> + <Tooltip content={dayjs(message.edited).format("LLLL")}> + <Text id="app.main.channel.edited" /> + </Tooltip> + </span> + </> + ); + } else { + return ( + <> + <time> + <i className="copyBracket">[</i> + {dayjs(decodeTime(message._id)).format("H:mm")} + <i className="copyBracket">]</i> + </time> + </> + ); + } + } + + return ( + <DetailBase> + <time>{dayjs(decodeTime(message._id)).calendar()}</time> + {message.edited && ( + <Tooltip content={dayjs(message.edited).format("LLLL")}> + <Text id="app.main.channel.edited" /> + </Tooltip> + )} + </DetailBase> + ); } diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx index 486f6f28a8da03f0477f3477cc7b95764aae47ff..a1e78ad72006820d208a872bb434390e7a3027d9 100644 --- a/src/components/common/messaging/MessageBox.tsx +++ b/src/components/common/messaging/MessageBox.tsx @@ -1,388 +1,496 @@ -import { ulid } from "ulid"; -import { Text } from "preact-i18n"; +import { ShieldX } from "@styled-icons/boxicons-regular"; +import { Send } from "@styled-icons/boxicons-solid"; +import Axios, { CancelTokenSource } from "axios"; import { Channel } from "revolt.js"; +import { ChannelPermission } from "revolt.js/dist/api/permissions"; import styled from "styled-components"; -import { dispatch } from "../../../redux"; -import { defer } from "../../../lib/defer"; -import IconButton from "../../ui/IconButton"; -import { PermissionTooltip } from "../Tooltip"; -import { Send } from '@styled-icons/boxicons-solid'; +import { ulid } from "ulid"; + +import { Text } from "preact-i18n"; +import { useCallback, useContext, useEffect, useState } from "preact/hooks"; + +import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import { debounce } from "../../../lib/debounce"; -import Axios, { CancelTokenSource } from "axios"; +import { defer } from "../../../lib/defer"; +import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter"; import { useTranslation } from "../../../lib/i18n"; -import { Reply } from "../../../redux/reducers/queue"; +import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; +import { + SingletonMessageRenderer, + SMOOTH_SCROLL_ON_RECEIVE, +} from "../../../lib/renderer/Singleton"; + +import { dispatch } from "../../../redux"; import { connectState } from "../../../redux/connector"; +import { Reply } from "../../../redux/reducers/queue"; + import { SoundContext } from "../../../context/Settings"; -import { takeError } from "../../../context/revoltjs/util"; -import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; -import AutoComplete, { useAutoComplete } from "../AutoComplete"; -import { ChannelPermission } from "revolt.js/dist/api/permissions"; +import { useIntermediate } from "../../../context/intermediate/Intermediate"; +import { + FileUploader, + grabFiles, + uploadFile, +} from "../../../context/revoltjs/FileUploads"; import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { useChannelPermission } from "../../../context/revoltjs/hooks"; -import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; -import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter"; -import { useCallback, useContext, useEffect, useState } from "preact/hooks"; -import { useIntermediate } from "../../../context/intermediate/Intermediate"; -import { FileUploader, grabFiles, uploadFile } from "../../../context/revoltjs/FileUploads"; -import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton"; -import { ShieldX } from "@styled-icons/boxicons-regular"; +import { takeError } from "../../../context/revoltjs/util"; +import IconButton from "../../ui/IconButton"; + +import AutoComplete, { useAutoComplete } from "../AutoComplete"; +import { PermissionTooltip } from "../Tooltip"; +import FilePreview from "./bars/FilePreview"; import ReplyBar from "./bars/ReplyBar"; -import FilePreview from './bars/FilePreview'; type Props = { - channel: Channel; - draft?: string; + channel: Channel; + draft?: string; }; export type UploadState = - | { type: "none" } - | { type: "attached"; files: File[] } - | { type: "uploading"; files: File[]; percent: number; cancel: CancelTokenSource } - | { type: "sending"; files: File[] } - | { type: "failed"; files: File[]; error: string }; + | { type: "none" } + | { type: "attached"; files: File[] } + | { + type: "uploading"; + files: File[]; + percent: number; + cancel: CancelTokenSource; + } + | { type: "sending"; files: File[] } + | { type: "failed"; files: File[]; error: string }; const Base = styled.div` - display: flex; - padding: 0 12px; - background: var(--message-box); - - textarea { - font-size: .875rem; - background: transparent; - } + display: flex; + padding: 0 12px; + background: var(--message-box); + + textarea { + font-size: 0.875rem; + background: transparent; + } `; const Blocked = styled.div` - display: flex; - align-items: center; - padding: 14px 0; - user-select: none; - font-size: .875rem; - color: var(--tertiary-foreground); - - svg { - flex-shrink: 0; - margin-inline-end: 10px; - } + display: flex; + align-items: center; + padding: 14px 0; + user-select: none; + font-size: 0.875rem; + color: var(--tertiary-foreground); + + svg { + flex-shrink: 0; + margin-inline-end: 10px; + } `; const Action = styled.div` - display: grid; - place-items: center; + display: grid; + place-items: center; `; // ! FIXME: add to app config and load from app config export const CAN_UPLOAD_AT_ONCE = 5; function MessageBox({ channel, draft }: Props) { - const [ uploadState, setUploadState ] = useState<UploadState>({ type: 'none' }); - const [ typing, setTyping ] = useState<boolean | number>(false); - const [ replies, setReplies ] = useState<Reply[]>([]); - const playSound = useContext(SoundContext); - const { openScreen } = useIntermediate(); - const client = useContext(AppContext); - const translate = useTranslation(); - - const permissions = useChannelPermission(channel._id); - if (!(permissions & ChannelPermission.SendMessage)) { - return ( - <Base> - <Blocked> - <PermissionTooltip permission="SendMessages" placement="top"> - <ShieldX size={22}/> - </PermissionTooltip> - <Text id="app.main.channel.misc.no_sending" /> - </Blocked> - </Base> - ) - } - - function setMessage(content?: string) { - if (content) { - dispatch({ - type: "SET_DRAFT", - channel: channel._id, - content - }); - } else { - dispatch({ - type: "CLEAR_DRAFT", - channel: channel._id - }); - } - } - - useEffect(() => { - function append(content: string, action: 'quote' | 'mention') { - const text = - action === "quote" - ? `${content - .split("\n") - .map(x => `> ${x}`) - .join("\n")}\n\n` - : `${content} `; - - if (!draft || draft.length === 0) { - setMessage(text); - } else { - setMessage(`${draft}\n${text}`); - } - } - - return internalSubscribe("MessageBox", "append", append); - }, [ draft ]); - - async function send() { - if (uploadState.type === 'uploading' || uploadState.type === 'sending') return; - - const content = draft?.trim() ?? ''; - if (uploadState.type === 'attached') return sendFile(content); - if (content.length === 0) return; - - stopTyping(); - setMessage(); - setReplies([]); - playSound('outbound'); - - const nonce = ulid(); - dispatch({ - type: "QUEUE_ADD", - nonce, - channel: channel._id, - message: { - _id: nonce, - channel: channel._id, - author: client.user!._id, - - content, - replies - } - }); - - defer(() => SingletonMessageRenderer.jumpToBottom(channel._id, SMOOTH_SCROLL_ON_RECEIVE)); - - try { - await client.channels.sendMessage(channel._id, { - content, - nonce, - replies - }); - } catch (error) { - dispatch({ - type: "QUEUE_FAIL", - error: takeError(error), - nonce - }); - } - } - - async function sendFile(content: string) { - if (uploadState.type !== 'attached') return; - let attachments: string[] = []; - - const cancel = Axios.CancelToken.source(); - const files = uploadState.files; - stopTyping(); - setUploadState({ type: "uploading", files, percent: 0, cancel }); - - try { - for (let i=0;i<files.length&&i<CAN_UPLOAD_AT_ONCE;i++) { - const file = files[i]; - attachments.push( - await uploadFile(client.configuration!.features.autumn.url, 'attachments', file, { - onUploadProgress: e => - setUploadState({ - type: "uploading", - files, - percent: Math.round(((i * 100) + (100 * e.loaded) / e.total) / Math.min(files.length, CAN_UPLOAD_AT_ONCE)), - cancel - }), - cancelToken: cancel.token - }) - ); - } - } catch (err) { - if (err?.message === "cancel") { - setUploadState({ - type: "attached", - files - }); - } else { - setUploadState({ - type: "failed", - files, - error: takeError(err) - }); - } - - return; - } - - setUploadState({ - type: "sending", - files - }); - - const nonce = ulid(); - try { - await client.channels.sendMessage(channel._id, { - content, - nonce, - replies, - attachments - }); - } catch (err) { - setUploadState({ - type: "failed", - files, - error: takeError(err) - }); - - return; - } - - setMessage(); - setReplies([]); - playSound('outbound'); - - if (files.length > CAN_UPLOAD_AT_ONCE) { - setUploadState({ - type: "attached", - files: files.slice(CAN_UPLOAD_AT_ONCE) - }); - } else { - setUploadState({ type: "none" }); - } - } - - function startTyping() { - if (typeof typing === 'number' && + new Date() < typing) return; - - const ws = client.websocket; - if (ws.connected) { - setTyping(+ new Date() + 4000); - ws.send({ - type: "BeginTyping", - channel: channel._id - }); - } - } - - function stopTyping(force?: boolean) { - if (force || typing) { - const ws = client.websocket; - if (ws.connected) { - setTyping(false); - ws.send({ - type: "EndTyping", - channel: channel._id - }); - } - } - } - - const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [ channel._id ]); - const { onChange, onKeyUp, onKeyDown, onFocus, onBlur, ...autoCompleteProps } = - useAutoComplete(setMessage, { - users: { type: 'channel', id: channel._id }, - channels: channel.channel_type === 'TextChannel' ? { server: channel.server } : undefined - }); - - return ( - <> - <AutoComplete {...autoCompleteProps} /> - <FilePreview state={uploadState} addFile={() => uploadState.type === 'attached' && - grabFiles(20_000_000, files => setUploadState({ type: 'attached', files: [ ...uploadState.files, ...files ] }), - () => openScreen({ id: "error", error: "FileTooLarge" }), true)} - removeFile={index => { - if (uploadState.type !== 'attached') return; - if (uploadState.files.length === 1) { - setUploadState({ type: 'none' }); - } else { - setUploadState({ type: 'attached', files: uploadState.files.filter((_, i) => index !== i) }); - } - }} /> - <ReplyBar channel={channel._id} replies={replies} setReplies={setReplies} /> - <Base> - { (permissions & ChannelPermission.UploadFiles) ? <Action> - <FileUploader - size={24} - behaviour='multi' - style='attachment' - fileType='attachments' - maxFileSize={20_000_000} - - attached={uploadState.type !== 'none'} - uploading={uploadState.type === 'uploading' || uploadState.type === 'sending'} - - remove={async () => setUploadState({ type: "none" })} - onChange={files => setUploadState({ type: "attached", files })} - cancel={() => uploadState.type === 'uploading' && uploadState.cancel.cancel("cancel")} - append={files => { - if (files.length === 0) return; - - if (uploadState.type === 'none') { - setUploadState({ type: 'attached', files }); - } else if (uploadState.type === 'attached') { - setUploadState({ type: 'attached', files: [ ...uploadState.files, ...files ] }); - } - }} - /> - </Action> : undefined } - <TextAreaAutoSize - autoFocus - hideBorder - maxRows={5} - padding={14} - id="message" - value={draft ?? ''} - onKeyUp={onKeyUp} - onKeyDown={e => { - if (onKeyDown(e)) return; - - if ( - e.key === "ArrowUp" && - (!draft || draft.length === 0) - ) { - e.preventDefault(); - internalEmit("MessageRenderer", "edit_last"); - return; - } - - if (!e.shiftKey && e.key === "Enter" && !isTouchscreenDevice) { - e.preventDefault(); - return send(); - } - - debouncedStopTyping(true); - }} - placeholder={ - channel.channel_type === "DirectMessage" ? translate("app.main.channel.message_who", { - person: client.users.get(client.channels.getRecipient(channel._id))?.username }) - : channel.channel_type === "SavedMessages" ? translate("app.main.channel.message_saved") - : translate("app.main.channel.message_where", { channel_name: channel.name }) - } - disabled={uploadState.type === 'uploading' || uploadState.type === 'sending'} - onChange={e => { - setMessage(e.currentTarget.value); - startTyping(); - onChange(e); - }} - onFocus={onFocus} - onBlur={onBlur} /> - { isTouchscreenDevice && <Action> - <IconButton onClick={send}> - <Send size={20} /> - </IconButton> - </Action> } - </Base> - </> - ) + const [uploadState, setUploadState] = useState<UploadState>({ + type: "none", + }); + const [typing, setTyping] = useState<boolean | number>(false); + const [replies, setReplies] = useState<Reply[]>([]); + const playSound = useContext(SoundContext); + const { openScreen } = useIntermediate(); + const client = useContext(AppContext); + const translate = useTranslation(); + + const permissions = useChannelPermission(channel._id); + if (!(permissions & ChannelPermission.SendMessage)) { + return ( + <Base> + <Blocked> + <PermissionTooltip + permission="SendMessages" + placement="top"> + <ShieldX size={22} /> + </PermissionTooltip> + <Text id="app.main.channel.misc.no_sending" /> + </Blocked> + </Base> + ); + } + + function setMessage(content?: string) { + if (content) { + dispatch({ + type: "SET_DRAFT", + channel: channel._id, + content, + }); + } else { + dispatch({ + type: "CLEAR_DRAFT", + channel: channel._id, + }); + } + } + + useEffect(() => { + function append(content: string, action: "quote" | "mention") { + const text = + action === "quote" + ? `${content + .split("\n") + .map((x) => `> ${x}`) + .join("\n")}\n\n` + : `${content} `; + + if (!draft || draft.length === 0) { + setMessage(text); + } else { + setMessage(`${draft}\n${text}`); + } + } + + return internalSubscribe("MessageBox", "append", append); + }, [draft]); + + async function send() { + if (uploadState.type === "uploading" || uploadState.type === "sending") + return; + + const content = draft?.trim() ?? ""; + if (uploadState.type === "attached") return sendFile(content); + if (content.length === 0) return; + + stopTyping(); + setMessage(); + setReplies([]); + playSound("outbound"); + + const nonce = ulid(); + dispatch({ + type: "QUEUE_ADD", + nonce, + channel: channel._id, + message: { + _id: nonce, + channel: channel._id, + author: client.user!._id, + + content, + replies, + }, + }); + + defer(() => + SingletonMessageRenderer.jumpToBottom( + channel._id, + SMOOTH_SCROLL_ON_RECEIVE, + ), + ); + + try { + await client.channels.sendMessage(channel._id, { + content, + nonce, + replies, + }); + } catch (error) { + dispatch({ + type: "QUEUE_FAIL", + error: takeError(error), + nonce, + }); + } + } + + async function sendFile(content: string) { + if (uploadState.type !== "attached") return; + let attachments: string[] = []; + + const cancel = Axios.CancelToken.source(); + const files = uploadState.files; + stopTyping(); + setUploadState({ type: "uploading", files, percent: 0, cancel }); + + try { + for (let i = 0; i < files.length && i < CAN_UPLOAD_AT_ONCE; i++) { + const file = files[i]; + attachments.push( + await uploadFile( + client.configuration!.features.autumn.url, + "attachments", + file, + { + onUploadProgress: (e) => + setUploadState({ + type: "uploading", + files, + percent: Math.round( + (i * 100 + (100 * e.loaded) / e.total) / + Math.min( + files.length, + CAN_UPLOAD_AT_ONCE, + ), + ), + cancel, + }), + cancelToken: cancel.token, + }, + ), + ); + } + } catch (err) { + if (err?.message === "cancel") { + setUploadState({ + type: "attached", + files, + }); + } else { + setUploadState({ + type: "failed", + files, + error: takeError(err), + }); + } + + return; + } + + setUploadState({ + type: "sending", + files, + }); + + const nonce = ulid(); + try { + await client.channels.sendMessage(channel._id, { + content, + nonce, + replies, + attachments, + }); + } catch (err) { + setUploadState({ + type: "failed", + files, + error: takeError(err), + }); + + return; + } + + setMessage(); + setReplies([]); + playSound("outbound"); + + if (files.length > CAN_UPLOAD_AT_ONCE) { + setUploadState({ + type: "attached", + files: files.slice(CAN_UPLOAD_AT_ONCE), + }); + } else { + setUploadState({ type: "none" }); + } + } + + function startTyping() { + if (typeof typing === "number" && +new Date() < typing) return; + + const ws = client.websocket; + if (ws.connected) { + setTyping(+new Date() + 4000); + ws.send({ + type: "BeginTyping", + channel: channel._id, + }); + } + } + + function stopTyping(force?: boolean) { + if (force || typing) { + const ws = client.websocket; + if (ws.connected) { + setTyping(false); + ws.send({ + type: "EndTyping", + channel: channel._id, + }); + } + } + } + + const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [ + channel._id, + ]); + const { + onChange, + onKeyUp, + onKeyDown, + onFocus, + onBlur, + ...autoCompleteProps + } = useAutoComplete(setMessage, { + users: { type: "channel", id: channel._id }, + channels: + channel.channel_type === "TextChannel" + ? { server: channel.server } + : undefined, + }); + + return ( + <> + <AutoComplete {...autoCompleteProps} /> + <FilePreview + state={uploadState} + addFile={() => + uploadState.type === "attached" && + grabFiles( + 20_000_000, + (files) => + setUploadState({ + type: "attached", + files: [...uploadState.files, ...files], + }), + () => + openScreen({ id: "error", error: "FileTooLarge" }), + true, + ) + } + removeFile={(index) => { + if (uploadState.type !== "attached") return; + if (uploadState.files.length === 1) { + setUploadState({ type: "none" }); + } else { + setUploadState({ + type: "attached", + files: uploadState.files.filter( + (_, i) => index !== i, + ), + }); + } + }} + /> + <ReplyBar + channel={channel._id} + replies={replies} + setReplies={setReplies} + /> + <Base> + {permissions & ChannelPermission.UploadFiles ? ( + <Action> + <FileUploader + size={24} + behaviour="multi" + style="attachment" + fileType="attachments" + maxFileSize={20_000_000} + attached={uploadState.type !== "none"} + uploading={ + uploadState.type === "uploading" || + uploadState.type === "sending" + } + remove={async () => + setUploadState({ type: "none" }) + } + onChange={(files) => + setUploadState({ type: "attached", files }) + } + cancel={() => + uploadState.type === "uploading" && + uploadState.cancel.cancel("cancel") + } + append={(files) => { + if (files.length === 0) return; + + if (uploadState.type === "none") { + setUploadState({ type: "attached", files }); + } else if (uploadState.type === "attached") { + setUploadState({ + type: "attached", + files: [...uploadState.files, ...files], + }); + } + }} + /> + </Action> + ) : undefined} + <TextAreaAutoSize + autoFocus + hideBorder + maxRows={5} + padding={14} + id="message" + value={draft ?? ""} + onKeyUp={onKeyUp} + onKeyDown={(e) => { + if (onKeyDown(e)) return; + + if ( + e.key === "ArrowUp" && + (!draft || draft.length === 0) + ) { + e.preventDefault(); + internalEmit("MessageRenderer", "edit_last"); + return; + } + + if ( + !e.shiftKey && + e.key === "Enter" && + !isTouchscreenDevice + ) { + e.preventDefault(); + return send(); + } + + debouncedStopTyping(true); + }} + placeholder={ + channel.channel_type === "DirectMessage" + ? translate("app.main.channel.message_who", { + person: client.users.get( + client.channels.getRecipient( + channel._id, + ), + )?.username, + }) + : channel.channel_type === "SavedMessages" + ? translate("app.main.channel.message_saved") + : translate("app.main.channel.message_where", { + channel_name: channel.name, + }) + } + disabled={ + uploadState.type === "uploading" || + uploadState.type === "sending" + } + onChange={(e) => { + setMessage(e.currentTarget.value); + startTyping(); + onChange(e); + }} + onFocus={onFocus} + onBlur={onBlur} + /> + {isTouchscreenDevice && ( + <Action> + <IconButton onClick={send}> + <Send size={20} /> + </IconButton> + </Action> + )} + </Base> + </> + ); } -export default connectState<Omit<Props, "dispatch" | "draft">>(MessageBox, (state, { channel }) => { - return { - draft: state.drafts[channel._id] - } -}, true) +export default connectState<Omit<Props, "dispatch" | "draft">>( + MessageBox, + (state, { channel }) => { + return { + draft: state.drafts[channel._id], + }; + }, + true, +); diff --git a/src/components/common/messaging/SystemMessage.tsx b/src/components/common/messaging/SystemMessage.tsx index e239076d71c2fd1dae4338a14ee6081f6e2dbeac..ecd78dc5938589488a681acb95d1a837089057bc 100644 --- a/src/components/common/messaging/SystemMessage.tsx +++ b/src/components/common/messaging/SystemMessage.tsx @@ -1,149 +1,160 @@ import { User } from "revolt.js"; import styled from "styled-components"; -import UserShort from "../user/UserShort"; -import { TextReact } from "../../../lib/i18n"; + import { attachContextMenu } from "preact-context-menu"; + +import { TextReact } from "../../../lib/i18n"; + +import { useForceUpdate, useUser } from "../../../context/revoltjs/hooks"; import { MessageObject } from "../../../context/revoltjs/util"; + +import UserShort from "../user/UserShort"; import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase"; -import { useForceUpdate, useUser } from "../../../context/revoltjs/hooks"; const SystemContent = styled.div` - gap: 4px; - display: flex; - padding: 2px 0; - flex-wrap: wrap; - align-items: center; - flex-direction: row; + gap: 4px; + display: flex; + padding: 2px 0; + flex-wrap: wrap; + align-items: center; + flex-direction: row; `; type SystemMessageParsed = - | { type: "text"; content: string } - | { type: "user_added"; user: User; by: User } - | { type: "user_remove"; user: User; by: User } - | { type: "user_joined"; user: User } - | { type: "user_left"; user: User } - | { type: "user_kicked"; user: User } - | { type: "user_banned"; user: User } - | { type: "channel_renamed"; name: string; by: User } - | { type: "channel_description_changed"; by: User } - | { type: "channel_icon_changed"; by: User }; + | { type: "text"; content: string } + | { type: "user_added"; user: User; by: User } + | { type: "user_remove"; user: User; by: User } + | { type: "user_joined"; user: User } + | { type: "user_left"; user: User } + | { type: "user_kicked"; user: User } + | { type: "user_banned"; user: User } + | { type: "channel_renamed"; name: string; by: User } + | { type: "channel_description_changed"; by: User } + | { type: "channel_icon_changed"; by: User }; interface Props { - attachContext?: boolean; - message: MessageObject; + attachContext?: boolean; + message: MessageObject; } export function SystemMessage({ attachContext, message }: Props) { - const ctx = useForceUpdate(); + const ctx = useForceUpdate(); - let data: SystemMessageParsed; - let content = message.content; - if (typeof content === "object") { - switch (content.type) { - case "text": - data = content; - break; - case "user_added": - case "user_remove": - data = { - type: content.type, - user: useUser(content.id, ctx) as User, - by: useUser(content.by, ctx) as User - }; - break; - case "user_joined": - case "user_left": - case "user_kicked": - case "user_banned": - data = { - type: content.type, - user: useUser(content.id, ctx) as User - }; - break; - case "channel_renamed": - data = { - type: "channel_renamed", - name: content.name, - by: useUser(content.by, ctx) as User - }; - break; - case "channel_description_changed": - case "channel_icon_changed": - data = { - type: content.type, - by: useUser(content.by, ctx) as User - }; - break; - default: - data = { type: "text", content: JSON.stringify(content) }; - } - } else { - data = { type: "text", content }; - } + let data: SystemMessageParsed; + let content = message.content; + if (typeof content === "object") { + switch (content.type) { + case "text": + data = content; + break; + case "user_added": + case "user_remove": + data = { + type: content.type, + user: useUser(content.id, ctx) as User, + by: useUser(content.by, ctx) as User, + }; + break; + case "user_joined": + case "user_left": + case "user_kicked": + case "user_banned": + data = { + type: content.type, + user: useUser(content.id, ctx) as User, + }; + break; + case "channel_renamed": + data = { + type: "channel_renamed", + name: content.name, + by: useUser(content.by, ctx) as User, + }; + break; + case "channel_description_changed": + case "channel_icon_changed": + data = { + type: content.type, + by: useUser(content.by, ctx) as User, + }; + break; + default: + data = { type: "text", content: JSON.stringify(content) }; + } + } else { + data = { type: "text", content }; + } - let children; - switch (data.type) { - case "text": - children = <span>{data.content}</span>; - break; - case "user_added": - case "user_remove": - children = ( - <TextReact - id={`app.main.channel.system.${data.type === 'user_added' ? "added_by" : "removed_by"}`} - fields={{ - user: <UserShort user={data.user} />, - other_user: <UserShort user={data.by} /> - }} - /> - ); - break; - case "user_joined": - case "user_left": - case "user_kicked": - case "user_banned": - children = ( - <TextReact - id={`app.main.channel.system.${data.type}`} - fields={{ - user: <UserShort user={data.user} /> - }} - /> - ); - break; - case "channel_renamed": - children = ( - <TextReact - id={`app.main.channel.system.channel_renamed`} - fields={{ - user: <UserShort user={data.by} />, - name: <b>{data.name}</b> - }} - /> - ); - break; - case "channel_description_changed": - case "channel_icon_changed": - children = ( - <TextReact - id={`app.main.channel.system.${data.type}`} - fields={{ - user: <UserShort user={data.by} /> - }} - /> - ); - break; - } + let children; + switch (data.type) { + case "text": + children = <span>{data.content}</span>; + break; + case "user_added": + case "user_remove": + children = ( + <TextReact + id={`app.main.channel.system.${ + data.type === "user_added" ? "added_by" : "removed_by" + }`} + fields={{ + user: <UserShort user={data.user} />, + other_user: <UserShort user={data.by} />, + }} + /> + ); + break; + case "user_joined": + case "user_left": + case "user_kicked": + case "user_banned": + children = ( + <TextReact + id={`app.main.channel.system.${data.type}`} + fields={{ + user: <UserShort user={data.user} />, + }} + /> + ); + break; + case "channel_renamed": + children = ( + <TextReact + id={`app.main.channel.system.channel_renamed`} + fields={{ + user: <UserShort user={data.by} />, + name: <b>{data.name}</b>, + }} + /> + ); + break; + case "channel_description_changed": + case "channel_icon_changed": + children = ( + <TextReact + id={`app.main.channel.system.${data.type}`} + fields={{ + user: <UserShort user={data.by} />, + }} + /> + ); + break; + } - return ( - <MessageBase - onContextMenu={attachContext ? attachContextMenu('Menu', - { message, contextualChannel: message.channel } - ) : undefined}> - <MessageInfo> - <MessageDetail message={message} position="left" /> - </MessageInfo> - <SystemContent>{children}</SystemContent> - </MessageBase> - ); + return ( + <MessageBase + onContextMenu={ + attachContext + ? attachContextMenu("Menu", { + message, + contextualChannel: message.channel, + }) + : undefined + }> + <MessageInfo> + <MessageDetail message={message} position="left" /> + </MessageInfo> + <SystemContent>{children}</SystemContent> + </MessageBase> + ); } diff --git a/src/components/common/messaging/attachments/Attachment.tsx b/src/components/common/messaging/attachments/Attachment.tsx index 2ef979a2c8c22b29d0fbbd835cb3aad8357f44df..4f88feb7aa693b790fdfdc14bc0d5d0d8af6abbe 100644 --- a/src/components/common/messaging/attachments/Attachment.tsx +++ b/src/components/common/messaging/attachments/Attachment.tsx @@ -1,126 +1,133 @@ -import TextFile from "./TextFile"; -import { Text } from "preact-i18n"; -import classNames from "classnames"; +import { Attachment as AttachmentRJS } from "revolt.js/dist/api/objects"; + import styles from "./Attachment.module.scss"; -import AttachmentActions from "./AttachmentActions"; +import classNames from "classnames"; +import { Text } from "preact-i18n"; import { useContext, useState } from "preact/hooks"; -import { AppContext } from "../../../../context/revoltjs/RevoltClient"; -import { Attachment as AttachmentRJS } from "revolt.js/dist/api/objects"; + import { useIntermediate } from "../../../../context/intermediate/Intermediate"; +import { AppContext } from "../../../../context/revoltjs/RevoltClient"; + import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea"; +import AttachmentActions from "./AttachmentActions"; +import TextFile from "./TextFile"; interface Props { - attachment: AttachmentRJS; - hasContent: boolean; + attachment: AttachmentRJS; + hasContent: boolean; } const MAX_ATTACHMENT_WIDTH = 480; export default function Attachment({ attachment, hasContent }: Props) { - const client = useContext(AppContext); - const { openScreen } = useIntermediate(); - const { filename, metadata } = attachment; - const [ spoiler, setSpoiler ] = useState(filename.startsWith("SPOILER_")); - const [ loaded, setLoaded ] = useState(false) - - const url = client.generateFileURL(attachment, { width: MAX_ATTACHMENT_WIDTH * 1.5 }, true); + const client = useContext(AppContext); + const { openScreen } = useIntermediate(); + const { filename, metadata } = attachment; + const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_")); + const [loaded, setLoaded] = useState(false); + const url = client.generateFileURL( + attachment, + { width: MAX_ATTACHMENT_WIDTH * 1.5 }, + true, + ); - switch (metadata.type) { - case "Image": { - return ( - <div - className={styles.container} - onClick={() => spoiler && setSpoiler(false)} - > - {spoiler && ( - <div className={styles.overflow}> - <span><Text id="app.main.channel.misc.spoiler_attachment" /></span> - </div> - )} - <img - src={url} - alt={filename} - width={metadata.width} - height={metadata.height} - data-spoiler={spoiler} - data-has-content={hasContent} - className={classNames(styles.attachment, styles.image, loaded && styles.loaded)} - onClick={() => - openScreen({ id: "image_viewer", attachment }) - } - onMouseDown={ev => - ev.button === 1 && - window.open(url, "_blank") - } - onLoad={() => setLoaded(true)} - /> - </div> - ); - } - case "Audio": { - return ( - <div - className={classNames(styles.attachment, styles.audio)} - data-has-content={hasContent} - > - <AttachmentActions attachment={attachment} /> - <audio src={url} controls /> - </div> - ); - } - case "Video": { - return ( - <div - className={styles.container} - onClick={() => spoiler && setSpoiler(false)}> - {spoiler && ( - <div className={styles.overflow}> - <span><Text id="app.main.channel.misc.spoiler_attachment" /></span> - </div> - )} - <div - data-spoiler={spoiler} - data-has-content={hasContent} - className={classNames(styles.attachment, styles.video)} - > - <AttachmentActions attachment={attachment} /> - <video - src={url} - width={metadata.width} - height={metadata.height} - className={classNames(loaded && styles.loaded)} - controls - onMouseDown={ev => - ev.button === 1 && - window.open(url, "_blank") - } - onLoadedMetadata={() => setLoaded(true)} - /> - </div> - </div> - ); - } - case 'Text': { - return ( - <div - className={classNames(styles.attachment, styles.text)} - data-has-content={hasContent} - > - <TextFile attachment={attachment} /> - <AttachmentActions attachment={attachment} /> - </div> - ); - } - default: { - return ( - <div - className={classNames(styles.attachment, styles.file)} - data-has-content={hasContent} - > - <AttachmentActions attachment={attachment} /> - </div> - ); - } - } + switch (metadata.type) { + case "Image": { + return ( + <div + className={styles.container} + onClick={() => spoiler && setSpoiler(false)}> + {spoiler && ( + <div className={styles.overflow}> + <span> + <Text id="app.main.channel.misc.spoiler_attachment" /> + </span> + </div> + )} + <img + src={url} + alt={filename} + width={metadata.width} + height={metadata.height} + data-spoiler={spoiler} + data-has-content={hasContent} + className={classNames( + styles.attachment, + styles.image, + loaded && styles.loaded, + )} + onClick={() => + openScreen({ id: "image_viewer", attachment }) + } + onMouseDown={(ev) => + ev.button === 1 && window.open(url, "_blank") + } + onLoad={() => setLoaded(true)} + /> + </div> + ); + } + case "Audio": { + return ( + <div + className={classNames(styles.attachment, styles.audio)} + data-has-content={hasContent}> + <AttachmentActions attachment={attachment} /> + <audio src={url} controls /> + </div> + ); + } + case "Video": { + return ( + <div + className={styles.container} + onClick={() => spoiler && setSpoiler(false)}> + {spoiler && ( + <div className={styles.overflow}> + <span> + <Text id="app.main.channel.misc.spoiler_attachment" /> + </span> + </div> + )} + <div + data-spoiler={spoiler} + data-has-content={hasContent} + className={classNames(styles.attachment, styles.video)}> + <AttachmentActions attachment={attachment} /> + <video + src={url} + width={metadata.width} + height={metadata.height} + className={classNames(loaded && styles.loaded)} + controls + onMouseDown={(ev) => + ev.button === 1 && window.open(url, "_blank") + } + onLoadedMetadata={() => setLoaded(true)} + /> + </div> + </div> + ); + } + case "Text": { + return ( + <div + className={classNames(styles.attachment, styles.text)} + data-has-content={hasContent}> + <TextFile attachment={attachment} /> + <AttachmentActions attachment={attachment} /> + </div> + ); + } + default: { + return ( + <div + className={classNames(styles.attachment, styles.file)} + data-has-content={hasContent}> + <AttachmentActions attachment={attachment} /> + </div> + ); + } + } } diff --git a/src/components/common/messaging/attachments/AttachmentActions.tsx b/src/components/common/messaging/attachments/AttachmentActions.tsx index 8b297f4becb000814764698ea314b911d53e7186..b5458c9cf6e923c1c2e32e08159f52003a948386 100644 --- a/src/components/common/messaging/attachments/AttachmentActions.tsx +++ b/src/components/common/messaging/attachments/AttachmentActions.tsx @@ -1,83 +1,115 @@ -import { useContext } from 'preact/hooks'; -import styles from './Attachment.module.scss'; -import IconButton from '../../../ui/IconButton'; +import { + Download, + LinkExternal, + File, + Headphone, + Video, +} from "@styled-icons/boxicons-regular"; import { Attachment } from "revolt.js/dist/api/objects"; -import { determineFileSize } from '../../../../lib/fileSize'; -import { AppContext } from '../../../../context/revoltjs/RevoltClient'; -import { Download, LinkExternal, File, Headphone, Video } from '@styled-icons/boxicons-regular'; -import classNames from 'classnames'; + +import styles from "./Attachment.module.scss"; +import classNames from "classnames"; +import { useContext } from "preact/hooks"; + +import { determineFileSize } from "../../../../lib/fileSize"; + +import { AppContext } from "../../../../context/revoltjs/RevoltClient"; + +import IconButton from "../../../ui/IconButton"; interface Props { - attachment: Attachment; + attachment: Attachment; } export default function AttachmentActions({ attachment }: Props) { - const client = useContext(AppContext); - const { filename, metadata, size } = attachment; + const client = useContext(AppContext); + const { filename, metadata, size } = attachment; - const url = client.generateFileURL(attachment)!; - const open_url = `${url}/${filename}`; - const download_url = url.replace('attachments', 'attachments/download') + const url = client.generateFileURL(attachment)!; + const open_url = `${url}/${filename}`; + const download_url = url.replace("attachments", "attachments/download"); - // for some reason revolt.js says the size is a string even though it's a number - const filesize = determineFileSize(size); + const filesize = determineFileSize(size); - switch (metadata.type) { - case 'Image': - return ( - <div className={classNames(styles.actions, styles.imageAction)}> - <span className={styles.filename}>{filename}</span> - <span className={styles.filesize}>{metadata.width + 'x' + metadata.height} ({filesize})</span> - <a href={open_url} target="_blank" className={styles.iconType} > - <IconButton> - <LinkExternal size={24} /> - </IconButton> - </a> - <a href={download_url} className={styles.downloadIcon} download target="_blank"> - <IconButton> - <Download size={24} /> - </IconButton> - </a> - </div> - ) - case 'Audio': - return ( - <div className={classNames(styles.actions, styles.audioAction)}> - <Headphone size={24} className={styles.iconType} /> - <span className={styles.filename}>{filename}</span> - <span className={styles.filesize}>{filesize}</span> - <a href={download_url} className={styles.downloadIcon} download target="_blank"> - <IconButton> - <Download size={24} /> - </IconButton> - </a> - </div> - ) - case 'Video': - return ( - <div className={classNames(styles.actions, styles.videoAction)}> - <Video size={24} className={styles.iconType} /> - <span className={styles.filename}>{filename}</span> - <span className={styles.filesize}>{metadata.width + 'x' + metadata.height} ({filesize})</span> - <a href={download_url} className={styles.downloadIcon} download target="_blank"> - <IconButton> - <Download size={24} /> - </IconButton> - </a> - </div> - ) - default: - return ( - <div className={styles.actions}> - <File size={24} className={styles.iconType} /> - <span className={styles.filename}>{filename}</span> - <span className={styles.filesize}>{filesize}</span> - <a href={download_url} className={styles.downloadIcon} download target="_blank"> - <IconButton> - <Download size={24} /> - </IconButton> - </a> - </div> - ) - } + switch (metadata.type) { + case "Image": + return ( + <div className={classNames(styles.actions, styles.imageAction)}> + <span className={styles.filename}>{filename}</span> + <span className={styles.filesize}> + {metadata.width + "x" + metadata.height} ({filesize}) + </span> + <a + href={open_url} + target="_blank" + className={styles.iconType}> + <IconButton> + <LinkExternal size={24} /> + </IconButton> + </a> + <a + href={download_url} + className={styles.downloadIcon} + download + target="_blank"> + <IconButton> + <Download size={24} /> + </IconButton> + </a> + </div> + ); + case "Audio": + return ( + <div className={classNames(styles.actions, styles.audioAction)}> + <Headphone size={24} className={styles.iconType} /> + <span className={styles.filename}>{filename}</span> + <span className={styles.filesize}>{filesize}</span> + <a + href={download_url} + className={styles.downloadIcon} + download + target="_blank"> + <IconButton> + <Download size={24} /> + </IconButton> + </a> + </div> + ); + case "Video": + return ( + <div className={classNames(styles.actions, styles.videoAction)}> + <Video size={24} className={styles.iconType} /> + <span className={styles.filename}>{filename}</span> + <span className={styles.filesize}> + {metadata.width + "x" + metadata.height} ({filesize}) + </span> + <a + href={download_url} + className={styles.downloadIcon} + download + target="_blank"> + <IconButton> + <Download size={24} /> + </IconButton> + </a> + </div> + ); + default: + return ( + <div className={styles.actions}> + <File size={24} className={styles.iconType} /> + <span className={styles.filename}>{filename}</span> + <span className={styles.filesize}>{filesize}</span> + <a + href={download_url} + className={styles.downloadIcon} + download + target="_blank"> + <IconButton> + <Download size={24} /> + </IconButton> + </a> + </div> + ); + } } diff --git a/src/components/common/messaging/attachments/MessageReply.tsx b/src/components/common/messaging/attachments/MessageReply.tsx index 95d931f01a95ba3a3299f7c605b9999636f0a11d..4a28383df134568f1e4a46cd1c82ca4f20d1ccfb 100644 --- a/src/components/common/messaging/attachments/MessageReply.tsx +++ b/src/components/common/messaging/attachments/MessageReply.tsx @@ -1,72 +1,93 @@ -import { Text } from "preact-i18n"; -import UserShort from "../../user/UserShort"; -import styled, { css } from "styled-components"; -import Markdown from "../../../markdown/Markdown"; import { Reply, File } from "@styled-icons/boxicons-regular"; -import { useUser } from "../../../../context/revoltjs/hooks"; +import styled, { css } from "styled-components"; + +import { Text } from "preact-i18n"; + import { useRenderState } from "../../../../lib/renderer/Singleton"; +import { useUser } from "../../../../context/revoltjs/hooks"; + +import Markdown from "../../../markdown/Markdown"; +import UserShort from "../../user/UserShort"; + interface Props { - channel: string - index: number - id: string + channel: string; + index: number; + id: string; } -export const ReplyBase = styled.div<{ head?: boolean, fail?: boolean, preview?: boolean }>` - gap: 4px; - display: flex; - font-size: 0.8em; - margin-left: 30px; - user-select: none; - margin-bottom: 4px; - align-items: center; - color: var(--secondary-foreground); +export const ReplyBase = styled.div<{ + head?: boolean; + fail?: boolean; + preview?: boolean; +}>` + gap: 4px; + display: flex; + font-size: 0.8em; + margin-left: 30px; + user-select: none; + margin-bottom: 4px; + align-items: center; + color: var(--secondary-foreground); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; - svg:first-child { - flex-shrink: 0; - transform: scaleX(-1); - color: var(--tertiary-foreground); - } + svg:first-child { + flex-shrink: 0; + transform: scaleX(-1); + color: var(--tertiary-foreground); + } - ${ props => props.fail && css` - color: var(--tertiary-foreground); - ` } + ${(props) => + props.fail && + css` + color: var(--tertiary-foreground); + `} - ${ props => props.head && css` - margin-top: 12px; - ` } + ${(props) => + props.head && + css` + margin-top: 12px; + `} - ${ props => props.preview && css` - margin-left: 0; - ` } + ${(props) => + props.preview && + css` + margin-left: 0; + `} `; export function MessageReply({ index, channel, id }: Props) { - const view = useRenderState(channel); - if (view?.type !== 'RENDER') return null; + const view = useRenderState(channel); + if (view?.type !== "RENDER") return null; - const message = view.messages.find(x => x._id === id); - if (!message) { - return ( - <ReplyBase head={index === 0} fail> - <Reply size={16} /> - <span><Text id="app.main.channel.misc.failed_load" /></span> - </ReplyBase> - ) - } + const message = view.messages.find((x) => x._id === id); + if (!message) { + return ( + <ReplyBase head={index === 0} fail> + <Reply size={16} /> + <span> + <Text id="app.main.channel.misc.failed_load" /> + </span> + </ReplyBase> + ); + } - const user = useUser(message.author); + const user = useUser(message.author); - return ( - <ReplyBase head={index === 0}> - <Reply size={16} /> - <UserShort user={user} size={16} /> - { message.attachments && message.attachments.length > 0 && <File size={16} /> } - <Markdown disallowBigEmoji content={(message.content as string).replace(/\n/g, ' ')} /> - </ReplyBase> - ) + return ( + <ReplyBase head={index === 0}> + <Reply size={16} /> + <UserShort user={user} size={16} /> + {message.attachments && message.attachments.length > 0 && ( + <File size={16} /> + )} + <Markdown + disallowBigEmoji + content={(message.content as string).replace(/\n/g, " ")} + /> + </ReplyBase> + ); } diff --git a/src/components/common/messaging/attachments/TextFile.tsx b/src/components/common/messaging/attachments/TextFile.tsx index c01976f566eafdce373b322ddb9856b497d4f5c1..a31d3d99d2a45c015c2511ccbcacbd9e32846425 100644 --- a/src/components/common/messaging/attachments/TextFile.tsx +++ b/src/components/common/messaging/attachments/TextFile.tsx @@ -1,57 +1,72 @@ -import axios from 'axios'; -import Preloader from '../../../ui/Preloader'; -import styles from './Attachment.module.scss'; -import { Attachment } from 'revolt.js/dist/api/objects'; -import { useContext, useEffect, useState } from 'preact/hooks'; -import RequiresOnline from '../../../../context/revoltjs/RequiresOnline'; -import { AppContext, StatusContext } from '../../../../context/revoltjs/RevoltClient'; +import axios from "axios"; +import { Attachment } from "revolt.js/dist/api/objects"; + +import styles from "./Attachment.module.scss"; +import { useContext, useEffect, useState } from "preact/hooks"; + +import RequiresOnline from "../../../../context/revoltjs/RequiresOnline"; +import { + AppContext, + StatusContext, +} from "../../../../context/revoltjs/RevoltClient"; + +import Preloader from "../../../ui/Preloader"; interface Props { - attachment: Attachment; + attachment: Attachment; } const fileCache: { [key: string]: string } = {}; export default function TextFile({ attachment }: Props) { - const [ content, setContent ] = useState<undefined | string>(undefined); - const [ loading, setLoading ] = useState(false); - const status = useContext(StatusContext); - const client = useContext(AppContext); - - const url = client.generateFileURL(attachment)!; - - useEffect(() => { - if (typeof content !== 'undefined') return; - if (loading) return; - setLoading(true); - - let cached = fileCache[attachment._id]; - if (cached) { - setContent(cached); - setLoading(false); - } else { - axios.get(url) - .then(res => { - setContent(res.data); - fileCache[attachment._id] = res.data; - setLoading(false); - }) - .catch(() => { - console.error("Failed to load text file. [", attachment._id, "]"); - setLoading(false) - }) - } - }, [ content, loading, status ]); - - return ( - <div className={styles.textContent} data-loading={typeof content === 'undefined'}> - { - content ? - <pre><code>{ content }</code></pre> - : <RequiresOnline> - <Preloader type="ring" /> - </RequiresOnline> - } - </div> - ) + const [content, setContent] = useState<undefined | string>(undefined); + const [loading, setLoading] = useState(false); + const status = useContext(StatusContext); + const client = useContext(AppContext); + + const url = client.generateFileURL(attachment)!; + + useEffect(() => { + if (typeof content !== "undefined") return; + if (loading) return; + setLoading(true); + + let cached = fileCache[attachment._id]; + if (cached) { + setContent(cached); + setLoading(false); + } else { + axios + .get(url) + .then((res) => { + setContent(res.data); + fileCache[attachment._id] = res.data; + setLoading(false); + }) + .catch(() => { + console.error( + "Failed to load text file. [", + attachment._id, + "]", + ); + setLoading(false); + }); + } + }, [content, loading, status]); + + return ( + <div + className={styles.textContent} + data-loading={typeof content === "undefined"}> + {content ? ( + <pre> + <code>{content}</code> + </pre> + ) : ( + <RequiresOnline> + <Preloader type="ring" /> + </RequiresOnline> + )} + </div> + ); } diff --git a/src/components/common/messaging/bars/FilePreview.tsx b/src/components/common/messaging/bars/FilePreview.tsx index 80f9fae594cb8f04aad16e8a1b42710b61322e23..92137d92f9619c437c7f99082d99495dba5ed8e7 100644 --- a/src/components/common/messaging/bars/FilePreview.tsx +++ b/src/components/common/messaging/bars/FilePreview.tsx @@ -1,194 +1,233 @@ -import { Text } from "preact-i18n"; +import { XCircle, Plus, Share, X, File } from "@styled-icons/boxicons-regular"; import styled from "styled-components"; + +import { Text } from "preact-i18n"; +import { useEffect, useState } from "preact/hooks"; + +import { determineFileSize } from "../../../../lib/fileSize"; + import { CAN_UPLOAD_AT_ONCE, UploadState } from "../MessageBox"; -import { useEffect, useState } from 'preact/hooks'; -import { determineFileSize } from '../../../../lib/fileSize'; -import { XCircle, Plus, Share, X, File } from "@styled-icons/boxicons-regular"; interface Props { - state: UploadState, - addFile: () => void, - removeFile: (index: number) => void + state: UploadState; + addFile: () => void; + removeFile: (index: number) => void; } const Container = styled.div` - gap: 4px; - padding: 8px; - display: flex; - user-select: none; - flex-direction: column; - background: var(--message-box); + gap: 4px; + padding: 8px; + display: flex; + user-select: none; + flex-direction: column; + background: var(--message-box); `; const Carousel = styled.div` - gap: 8px; - display: flex; - overflow-x: scroll; - flex-direction: row; + gap: 8px; + display: flex; + overflow-x: scroll; + flex-direction: row; `; const Entry = styled.div` - display: flex; - flex-direction: column; - - &.fade { - opacity: 0.4; - } - - span.fn { - margin: auto; - font-size: .8em; - overflow: hidden; - max-width: 180px; - text-align: center; - white-space: nowrap; - text-overflow: ellipsis; - color: var(--secondary-foreground); - } - - span.size { - font-size: .6em; - color: var(--tertiary-foreground); - text-align: center; - } + display: flex; + flex-direction: column; + + &.fade { + opacity: 0.4; + } + + span.fn { + margin: auto; + font-size: 0.8em; + overflow: hidden; + max-width: 180px; + text-align: center; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--secondary-foreground); + } + + span.size { + font-size: 0.6em; + color: var(--tertiary-foreground); + text-align: center; + } `; const Description = styled.div` - gap: 4px; - display: flex; - font-size: 0.9em; - align-items: center; - color: var(--secondary-foreground); + gap: 4px; + display: flex; + font-size: 0.9em; + align-items: center; + color: var(--secondary-foreground); `; const Divider = styled.div` - width: 4px; - height: 130px; - flex-shrink: 0; - border-radius: 4px; - background: var(--tertiary-background); + width: 4px; + height: 130px; + flex-shrink: 0; + border-radius: 4px; + background: var(--tertiary-background); `; const EmptyEntry = styled.div` - width: 100px; - height: 100px; - display: grid; - flex-shrink: 0; - cursor: pointer; - border-radius: 4px; - place-items: center; - background: var(--primary-background); - transition: 0.1s ease background-color; - - &:hover { - background: var(--secondary-background); - } + width: 100px; + height: 100px; + display: grid; + flex-shrink: 0; + cursor: pointer; + border-radius: 4px; + place-items: center; + background: var(--primary-background); + transition: 0.1s ease background-color; + + &:hover { + background: var(--secondary-background); + } `; const PreviewBox = styled.div` - display: grid; - grid-template: "main" 100px / minmax(100px, 1fr); - justify-items: center; - - background: var(--primary-background); - - overflow: hidden; - - cursor: pointer; - border-radius: 4px; - - .icon, .overlay { grid-area: main } - - .icon { - height: 100px; - width: 100%; - margin-bottom: 4px; - object-fit: contain; - } - - .overlay { - display: grid; - align-items: center; - justify-content: center; - - width: 100%; - height: 100%; - - opacity: 0; - visibility: hidden; - - transition: 0.1s ease opacity; - } - - &:hover { - .overlay { - visibility: visible; - opacity: 1; - background-color: rgba(0, 0, 0, 0.8); - } - } -` - -function FileEntry({ file, remove, index }: { file: File, remove?: () => void, index: number }) { - if (!file.type.startsWith('image/')) return ( - <Entry className={index >= CAN_UPLOAD_AT_ONCE ? 'fade' : ''}> - <PreviewBox onClick={remove}> - <EmptyEntry className="icon"> - <File size={36} /> - </EmptyEntry> - <div class="overlay"><XCircle size={36} /></div> - </PreviewBox> - <span class="fn">{file.name}</span> - <span class="size">{determineFileSize(file.size)}</span> - </Entry> - ); - - const [ url, setURL ] = useState(''); - - useEffect(() => { - let url: string = URL.createObjectURL(file); - setURL(url); - return () => URL.revokeObjectURL(url); - }, [ file ]); - - return ( - <Entry className={index >= CAN_UPLOAD_AT_ONCE ? 'fade' : ''}> - <PreviewBox onClick={remove}> - <img class="icon" src={url} alt={file.name} /> - <div class="overlay"><XCircle size={36} /></div> - </PreviewBox> - <span class="fn">{file.name}</span> - <span class="size">{determineFileSize(file.size)}</span> - </Entry> - ) + display: grid; + grid-template: "main" 100px / minmax(100px, 1fr); + justify-items: center; + + background: var(--primary-background); + + overflow: hidden; + + cursor: pointer; + border-radius: 4px; + + .icon, + .overlay { + grid-area: main; + } + + .icon { + height: 100px; + width: 100%; + margin-bottom: 4px; + object-fit: contain; + } + + .overlay { + display: grid; + align-items: center; + justify-content: center; + + width: 100%; + height: 100%; + + opacity: 0; + visibility: hidden; + + transition: 0.1s ease opacity; + } + + &:hover { + .overlay { + visibility: visible; + opacity: 1; + background-color: rgba(0, 0, 0, 0.8); + } + } +`; + +function FileEntry({ + file, + remove, + index, +}: { + file: File; + remove?: () => void; + index: number; +}) { + if (!file.type.startsWith("image/")) + return ( + <Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}> + <PreviewBox onClick={remove}> + <EmptyEntry className="icon"> + <File size={36} /> + </EmptyEntry> + <div class="overlay"> + <XCircle size={36} /> + </div> + </PreviewBox> + <span class="fn">{file.name}</span> + <span class="size">{determineFileSize(file.size)}</span> + </Entry> + ); + + const [url, setURL] = useState(""); + + useEffect(() => { + let url: string = URL.createObjectURL(file); + setURL(url); + return () => URL.revokeObjectURL(url); + }, [file]); + + return ( + <Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}> + <PreviewBox onClick={remove}> + <img class="icon" src={url} alt={file.name} /> + <div class="overlay"> + <XCircle size={36} /> + </div> + </PreviewBox> + <span class="fn">{file.name}</span> + <span class="size">{determineFileSize(file.size)}</span> + </Entry> + ); } export default function FilePreview({ state, addFile, removeFile }: Props) { - if (state.type === 'none') return null; - - return ( - <Container> - <Carousel> - { state.files.map((file, index) => - <> - { index === CAN_UPLOAD_AT_ONCE && <Divider /> } - <FileEntry index={index} file={file} key={file.name} remove={state.type === 'attached' ? () => removeFile(index) : undefined} /> - </> - ) } - { state.type === 'attached' && <EmptyEntry onClick={addFile}><Plus size={48} /></EmptyEntry> } - </Carousel> - { state.type === 'uploading' && <Description> - <Share size={24} /> - <Text id="app.main.channel.uploading_file" /> ({state.percent}%) - </Description> } - { state.type === 'sending' && <Description> - <Share size={24} /> - Sending... - </Description> } - { state.type === 'failed' && <Description> - <X size={24} /> - <Text id={`error.${state.error}`} /> - </Description> } - </Container> - ); + if (state.type === "none") return null; + + return ( + <Container> + <Carousel> + {state.files.map((file, index) => ( + <> + {index === CAN_UPLOAD_AT_ONCE && <Divider />} + <FileEntry + index={index} + file={file} + key={file.name} + remove={ + state.type === "attached" + ? () => removeFile(index) + : undefined + } + /> + </> + ))} + {state.type === "attached" && ( + <EmptyEntry onClick={addFile}> + <Plus size={48} /> + </EmptyEntry> + )} + </Carousel> + {state.type === "uploading" && ( + <Description> + <Share size={24} /> + <Text id="app.main.channel.uploading_file" /> ( + {state.percent}%) + </Description> + )} + {state.type === "sending" && ( + <Description> + <Share size={24} /> + Sending... + </Description> + )} + {state.type === "failed" && ( + <Description> + <X size={24} /> + <Text id={`error.${state.error}`} /> + </Description> + )} + </Container> + ); } diff --git a/src/components/common/messaging/bars/JumpToBottom.tsx b/src/components/common/messaging/bars/JumpToBottom.tsx index ff0f527879595554c7cae8131a9cae17375400e7..9accc90e88947004c4c29ecc281133c3bea6de02 100644 --- a/src/components/common/messaging/bars/JumpToBottom.tsx +++ b/src/components/common/messaging/bars/JumpToBottom.tsx @@ -1,53 +1,64 @@ -import { Text } from "preact-i18n"; -import styled from "styled-components"; import { DownArrow } from "@styled-icons/boxicons-regular"; -import { SingletonMessageRenderer, useRenderState } from "../../../../lib/renderer/Singleton"; +import styled from "styled-components"; + +import { Text } from "preact-i18n"; + +import { + SingletonMessageRenderer, + useRenderState, +} from "../../../../lib/renderer/Singleton"; const Bar = styled.div` - z-index: 10; - position: relative; - - > div { - top: -26px; - width: 100%; - position: absolute; - border-radius: 4px 4px 0 0; - display: flex; - cursor: pointer; - font-size: 13px; - padding: 4px 8px; - user-select: none; - color: var(--secondary-foreground); - background: var(--secondary-background); - justify-content: space-between; - transition: color ease-in-out .08s; - - > div { - display: flex; - align-items: center; - gap: 6px; - } - - &:hover { - color: var(--primary-text); - } - - &:active { - transform: translateY(1px); - } - } + z-index: 10; + position: relative; + + > div { + top: -26px; + width: 100%; + position: absolute; + border-radius: 4px 4px 0 0; + display: flex; + cursor: pointer; + font-size: 13px; + padding: 4px 8px; + user-select: none; + color: var(--secondary-foreground); + background: var(--secondary-background); + justify-content: space-between; + transition: color ease-in-out 0.08s; + + > div { + display: flex; + align-items: center; + gap: 6px; + } + + &:hover { + color: var(--primary-text); + } + + &:active { + transform: translateY(1px); + } + } `; export default function JumpToBottom({ id }: { id: string }) { - const view = useRenderState(id); - if (!view || view.type !== 'RENDER' || view.atBottom) return null; - - return ( - <Bar> - <div onClick={() => SingletonMessageRenderer.jumpToBottom(id, true)}> - <div><Text id="app.main.channel.misc.viewing_old" /></div> - <div><Text id="app.main.channel.misc.jump_present" /> <DownArrow size={18}/></div> - </div> - </Bar> - ) + const view = useRenderState(id); + if (!view || view.type !== "RENDER" || view.atBottom) return null; + + return ( + <Bar> + <div + onClick={() => SingletonMessageRenderer.jumpToBottom(id, true)}> + <div> + <Text id="app.main.channel.misc.viewing_old" /> + </div> + <div> + <Text id="app.main.channel.misc.jump_present" />{" "} + <DownArrow size={18} /> + </div> + </div> + </Bar> + ); } diff --git a/src/components/common/messaging/bars/ReplyBar.tsx b/src/components/common/messaging/bars/ReplyBar.tsx index 7ef6417399c327c56b16bd3a5d848e7c43fd7d8b..5c4dbdada2452d6afc6ce2b0ac7c19e5d9429c19 100644 --- a/src/components/common/messaging/bars/ReplyBar.tsx +++ b/src/components/common/messaging/bars/ReplyBar.tsx @@ -1,94 +1,141 @@ -import { Text } from "preact-i18n"; +import { + At, + Reply as ReplyIcon, + File, + XCircle, +} from "@styled-icons/boxicons-regular"; import styled from "styled-components"; -import UserShort from "../../user/UserShort"; -import IconButton from "../../../ui/IconButton"; -import Markdown from "../../../markdown/Markdown"; + +import { Text } from "preact-i18n"; import { StateUpdater, useEffect } from "preact/hooks"; -import { ReplyBase } from "../attachments/MessageReply"; -import { Reply } from "../../../../redux/reducers/queue"; -import { useUsers } from "../../../../context/revoltjs/hooks"; + import { internalSubscribe } from "../../../../lib/eventEmitter"; import { useRenderState } from "../../../../lib/renderer/Singleton"; -import { At, Reply as ReplyIcon, File, XCircle } from "@styled-icons/boxicons-regular"; + +import { Reply } from "../../../../redux/reducers/queue"; + +import { useUsers } from "../../../../context/revoltjs/hooks"; + +import IconButton from "../../../ui/IconButton"; + +import Markdown from "../../../markdown/Markdown"; +import UserShort from "../../user/UserShort"; +import { ReplyBase } from "../attachments/MessageReply"; interface Props { - channel: string, - replies: Reply[], - setReplies: StateUpdater<Reply[]> + channel: string; + replies: Reply[]; + setReplies: StateUpdater<Reply[]>; } const Base = styled.div` - display: flex; - padding: 0 22px; - user-select: none; - align-items: center; - background: var(--message-box); - - div { - flex-grow: 1; - } - - .actions { - gap: 12px; - display: flex; - } - - .toggle { - gap: 4px; - display: flex; - font-size: 0.7em; - align-items: center; - } + display: flex; + padding: 0 22px; + user-select: none; + align-items: center; + background: var(--message-box); + + div { + flex-grow: 1; + } + + .actions { + gap: 12px; + display: flex; + } + + .toggle { + gap: 4px; + display: flex; + font-size: 0.7em; + align-items: center; + } `; // ! FIXME: Move to global config const MAX_REPLIES = 5; export default function ReplyBar({ channel, replies, setReplies }: Props) { - useEffect(() => { - return internalSubscribe("ReplyBar", "add", id => replies.length < MAX_REPLIES && !replies.find(x => x.id === id) && setReplies([ ...replies, { id, mention: false } ])); - }, [ replies ]); - - const view = useRenderState(channel); - if (view?.type !== 'RENDER') return null; - - const ids = replies.map(x => x.id); - const messages = view.messages.filter(x => ids.includes(x._id)); - const users = useUsers(messages.map(x => x.author)); - - return ( - <div> - { replies.map((reply, index) => { - let message = messages.find(x => reply.id === x._id); - // ! FIXME: better solution would be to - // ! have a hook for resolving messages from - // ! render state along with relevant users - // -> which then fetches any unknown messages - if (!message) return <span><Text id="app.main.channel.misc.failed_load" /></span>; - - let user = users.find(x => message!.author === x?._id); - if (!user) return; - - return ( - <Base key={reply.id}> - <ReplyBase preview> - <ReplyIcon size={22} /> - <UserShort user={user} size={16} /> - { message.attachments && message.attachments.length > 0 && <File size={16} /> } - <Markdown disallowBigEmoji content={(message.content as string).replace(/\n/g, ' ')} /> - </ReplyBase> - <span class="actions"> - <IconButton onClick={() => setReplies(replies.map((_, i) => i === index ? { ..._, mention: !_.mention } : _))}> - <span class="toggle"> - <At size={16} /> { reply.mention ? 'ON' : 'OFF' } - </span> - </IconButton> - <IconButton onClick={() => setReplies(replies.filter((_, i) => i !== index))}> - <XCircle size={16} /> - </IconButton> - </span> - </Base> - ) - }) } - </div> - ) + useEffect(() => { + return internalSubscribe( + "ReplyBar", + "add", + (id) => + replies.length < MAX_REPLIES && + !replies.find((x) => x.id === id) && + setReplies([...replies, { id, mention: false }]), + ); + }, [replies]); + + const view = useRenderState(channel); + if (view?.type !== "RENDER") return null; + + const ids = replies.map((x) => x.id); + const messages = view.messages.filter((x) => ids.includes(x._id)); + const users = useUsers(messages.map((x) => x.author)); + + return ( + <div> + {replies.map((reply, index) => { + let message = messages.find((x) => reply.id === x._id); + // ! FIXME: better solution would be to + // ! have a hook for resolving messages from + // ! render state along with relevant users + // -> which then fetches any unknown messages + if (!message) + return ( + <span> + <Text id="app.main.channel.misc.failed_load" /> + </span> + ); + + let user = users.find((x) => message!.author === x?._id); + if (!user) return; + + return ( + <Base key={reply.id}> + <ReplyBase preview> + <ReplyIcon size={22} /> + <UserShort user={user} size={16} /> + {message.attachments && + message.attachments.length > 0 && ( + <File size={16} /> + )} + <Markdown + disallowBigEmoji + content={(message.content as string).replace( + /\n/g, + " ", + )} + /> + </ReplyBase> + <span class="actions"> + <IconButton + onClick={() => + setReplies( + replies.map((_, i) => + i === index + ? { ..._, mention: !_.mention } + : _, + ), + ) + }> + <span class="toggle"> + <At size={16} />{" "} + {reply.mention ? "ON" : "OFF"} + </span> + </IconButton> + <IconButton + onClick={() => + setReplies( + replies.filter((_, i) => i !== index), + ) + }> + <XCircle size={16} /> + </IconButton> + </span> + </Base> + ); + })} + </div> + ); } diff --git a/src/components/common/messaging/bars/TypingIndicator.tsx b/src/components/common/messaging/bars/TypingIndicator.tsx index d88601590166ef2a4d7eafea917ce16802f77b89..a00e2ff9854a7d62b7bbb37a73993d0d25a3096c 100644 --- a/src/components/common/messaging/bars/TypingIndicator.tsx +++ b/src/components/common/messaging/bars/TypingIndicator.tsx @@ -1,111 +1,121 @@ -import { User } from 'revolt.js'; +import { User } from "revolt.js"; +import styled from "styled-components"; + import { Text } from "preact-i18n"; -import styled from 'styled-components'; -import { useContext } from 'preact/hooks'; -import { connectState } from '../../../../redux/connector'; -import { useUsers } from '../../../../context/revoltjs/hooks'; -import { TypingUser } from '../../../../redux/reducers/typing'; -import { AppContext } from '../../../../context/revoltjs/RevoltClient'; +import { useContext } from "preact/hooks"; + +import { connectState } from "../../../../redux/connector"; +import { TypingUser } from "../../../../redux/reducers/typing"; + +import { AppContext } from "../../../../context/revoltjs/RevoltClient"; +import { useUsers } from "../../../../context/revoltjs/hooks"; interface Props { - typing?: TypingUser[] + typing?: TypingUser[]; } const Base = styled.div` - position: relative; - - > div { - height: 24px; - margin-top: -24px; - position: absolute; - - gap: 8px; - display: flex; - padding: 0 10px; - user-select: none; - align-items: center; - flex-direction: row; - width: calc(100% - 3px); - color: var(--secondary-foreground); - background: var(--secondary-background); - } - - .avatars { - display: flex; - - img { - width: 16px; - height: 16px; - object-fit: cover; - border-radius: 50%; - - &:not(:first-child) { - margin-left: -4px; - } - } - } - - .usernames { - min-width: 0; - font-size: 13px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } + position: relative; + + > div { + height: 24px; + margin-top: -24px; + position: absolute; + + gap: 8px; + display: flex; + padding: 0 10px; + user-select: none; + align-items: center; + flex-direction: row; + width: calc(100% - 3px); + color: var(--secondary-foreground); + background: var(--secondary-background); + } + + .avatars { + display: flex; + + img { + width: 16px; + height: 16px; + object-fit: cover; + border-radius: 50%; + + &:not(:first-child) { + margin-left: -4px; + } + } + } + + .usernames { + min-width: 0; + font-size: 13px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } `; export function TypingIndicator({ typing }: Props) { - if (typing && typing.length > 0) { - const client = useContext(AppContext); - const users = useUsers(typing.map(x => x.id)) - .filter(x => typeof x !== 'undefined') as User[]; - - users.sort((a, b) => a._id.toUpperCase().localeCompare(b._id.toUpperCase())); - - let text; - if (users.length >= 5) { - text = <Text id="app.main.channel.typing.several" />; - } else if (users.length > 1) { - const usersCopy = [...users]; - text = ( - <Text - id="app.main.channel.typing.multiple" - fields={{ - user: usersCopy.pop()?.username, - userlist: usersCopy.map(x => x.username).join(", ") - }} - /> - ); - } else { - text = ( - <Text - id="app.main.channel.typing.single" - fields={{ user: users[0].username }} - /> - ); - } - - return ( - <Base> - <div> - <div className="avatars"> - {users.map(user => ( - <img - src={client.users.getAvatarURL(user._id, { max_side: 256 }, true)} - /> - ))} - </div> - <div className="usernames">{text}</div> - </div> - </Base> - ); - } - - return null; + if (typing && typing.length > 0) { + const client = useContext(AppContext); + const users = useUsers(typing.map((x) => x.id)).filter( + (x) => typeof x !== "undefined", + ) as User[]; + + users.sort((a, b) => + a._id.toUpperCase().localeCompare(b._id.toUpperCase()), + ); + + let text; + if (users.length >= 5) { + text = <Text id="app.main.channel.typing.several" />; + } else if (users.length > 1) { + const usersCopy = [...users]; + text = ( + <Text + id="app.main.channel.typing.multiple" + fields={{ + user: usersCopy.pop()?.username, + userlist: usersCopy.map((x) => x.username).join(", "), + }} + /> + ); + } else { + text = ( + <Text + id="app.main.channel.typing.single" + fields={{ user: users[0].username }} + /> + ); + } + + return ( + <Base> + <div> + <div className="avatars"> + {users.map((user) => ( + <img + src={client.users.getAvatarURL( + user._id, + { max_side: 256 }, + true, + )} + /> + ))} + </div> + <div className="usernames">{text}</div> + </div> + </Base> + ); + } + + return null; } export default connectState<{ id: string }>(TypingIndicator, (state, props) => { - return { - typing: state.typing && state.typing[props.id] - }; + return { + typing: state.typing && state.typing[props.id], + }; }); diff --git a/src/components/common/messaging/embed/Embed.tsx b/src/components/common/messaging/embed/Embed.tsx index f0bd8d78d401ee527f4913fb39f57eee1f52191e..fa7a09fb8d772b6ba0f36f9e5732673d629a739a 100644 --- a/src/components/common/messaging/embed/Embed.tsx +++ b/src/components/common/messaging/embed/Embed.tsx @@ -1,13 +1,16 @@ -import classNames from 'classnames'; -import EmbedMedia from './EmbedMedia'; -import styles from "./Embed.module.scss"; -import { useContext } from 'preact/hooks'; import { Embed as EmbedRJS } from "revolt.js/dist/api/objects"; -import { useIntermediate } from '../../../../context/intermediate/Intermediate'; -import { MessageAreaWidthContext } from '../../../../pages/channels/messaging/MessageArea'; + +import styles from "./Embed.module.scss"; +import classNames from "classnames"; +import { useContext } from "preact/hooks"; + +import { useIntermediate } from "../../../../context/intermediate/Intermediate"; + +import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea"; +import EmbedMedia from "./EmbedMedia"; interface Props { - embed: EmbedRJS; + embed: EmbedRJS; } const MAX_EMBED_WIDTH = 480; @@ -16,113 +19,149 @@ const CONTAINER_PADDING = 24; const MAX_PREVIEW_SIZE = 150; export default function Embed({ embed }: Props) { - // ! FIXME: temp code - // ! add proxy function to client - function proxyImage(url: string) { - return 'https://jan.revolt.chat/proxy?url=' + encodeURIComponent(url); - } - - const { openScreen } = useIntermediate(); - const maxWidth = Math.min(useContext(MessageAreaWidthContext) - CONTAINER_PADDING, MAX_EMBED_WIDTH); - - function calculateSize(w: number, h: number): { width: number, height: number } { - let limitingWidth = Math.min( - maxWidth, - w - ); - - let limitingHeight = Math.min( - MAX_EMBED_HEIGHT, - h - ); - - // Calculate smallest possible WxH. - let width = Math.min( - limitingWidth, - limitingHeight * (w / h) - ); - - let height = Math.min( - limitingHeight, - limitingWidth * (h / w) - ); - - return { width, height }; - } - - switch (embed.type) { - case 'Website': { - // Determine special embed size. - let mw, mh; - let largeMedia = (embed.special && embed.special.type !== 'None') || embed.image?.size === 'Large'; - switch (embed.special?.type) { - case 'YouTube': - case 'Bandcamp': { - mw = embed.video?.width ?? 1280; - mh = embed.video?.height ?? 720; - break; - } - case 'Twitch': { - mw = 1280; - mh = 720; - break; - } - default: { - if (embed.image?.size === 'Preview') { - mw = MAX_EMBED_WIDTH; - mh = Math.min(embed.image.height ?? 0, MAX_PREVIEW_SIZE); - } else { - mw = embed.image?.width ?? MAX_EMBED_WIDTH; - mh = embed.image?.height ?? 0; - } - } - } - - let { width, height } = calculateSize(mw, mh); - return ( - <div - className={classNames(styles.embed, styles.website)} - style={{ - borderInlineStartColor: embed.color ?? 'var(--tertiary-background)', - width: width + CONTAINER_PADDING - }}> - <div> - { embed.site_name && <div className={styles.siteinfo}> - { embed.icon_url && <img className={styles.favicon} src={proxyImage(embed.icon_url)} draggable={false} onError={e => e.currentTarget.style.display = 'none'} /> } - <div className={styles.site}>{ embed.site_name } </div> - </div> } - - {/*<span><a href={embed.url} target={"_blank"} className={styles.author}>Author</a></span>*/} - { embed.title && <span><a href={embed.url} target={"_blank"} className={styles.title}>{ embed.title }</a></span> } - { embed.description && <div className={styles.description}>{ embed.description }</div> } - - { largeMedia && <EmbedMedia embed={embed} height={height} /> } - </div> - { - !largeMedia && <div> - <EmbedMedia embed={embed} width={height * ((embed.image?.width ?? 0) / (embed.image?.height ?? 0))} height={height} /> - </div> - } - </div> - ) - } - case 'Image': { - return ( - <img className={classNames(styles.embed, styles.image)} - style={calculateSize(embed.width, embed.height)} - src={proxyImage(embed.url)} - type="text/html" - frameBorder="0" - onClick={() => - openScreen({ id: "image_viewer", embed }) - } - onMouseDown={ev => - ev.button === 1 && - window.open(embed.url, "_blank") - } - /> - ) - } - default: return null; - } + // ! FIXME: temp code + // ! add proxy function to client + function proxyImage(url: string) { + return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url); + } + + const { openScreen } = useIntermediate(); + const maxWidth = Math.min( + useContext(MessageAreaWidthContext) - CONTAINER_PADDING, + MAX_EMBED_WIDTH, + ); + + function calculateSize( + w: number, + h: number, + ): { width: number; height: number } { + let limitingWidth = Math.min(maxWidth, w); + + let limitingHeight = Math.min(MAX_EMBED_HEIGHT, h); + + // Calculate smallest possible WxH. + let width = Math.min(limitingWidth, limitingHeight * (w / h)); + + let height = Math.min(limitingHeight, limitingWidth * (h / w)); + + return { width, height }; + } + + switch (embed.type) { + case "Website": { + // Determine special embed size. + let mw, mh; + let largeMedia = + (embed.special && embed.special.type !== "None") || + embed.image?.size === "Large"; + switch (embed.special?.type) { + case "YouTube": + case "Bandcamp": { + mw = embed.video?.width ?? 1280; + mh = embed.video?.height ?? 720; + break; + } + case "Twitch": { + mw = 1280; + mh = 720; + break; + } + default: { + if (embed.image?.size === "Preview") { + mw = MAX_EMBED_WIDTH; + mh = Math.min( + embed.image.height ?? 0, + MAX_PREVIEW_SIZE, + ); + } else { + mw = embed.image?.width ?? MAX_EMBED_WIDTH; + mh = embed.image?.height ?? 0; + } + } + } + + let { width, height } = calculateSize(mw, mh); + return ( + <div + className={classNames(styles.embed, styles.website)} + style={{ + borderInlineStartColor: + embed.color ?? "var(--tertiary-background)", + width: width + CONTAINER_PADDING, + }}> + <div> + {embed.site_name && ( + <div className={styles.siteinfo}> + {embed.icon_url && ( + <img + className={styles.favicon} + src={proxyImage(embed.icon_url)} + draggable={false} + onError={(e) => + (e.currentTarget.style.display = + "none") + } + /> + )} + <div className={styles.site}> + {embed.site_name}{" "} + </div> + </div> + )} + + {/*<span><a href={embed.url} target={"_blank"} className={styles.author}>Author</a></span>*/} + {embed.title && ( + <span> + <a + href={embed.url} + target={"_blank"} + className={styles.title}> + {embed.title} + </a> + </span> + )} + {embed.description && ( + <div className={styles.description}> + {embed.description} + </div> + )} + + {largeMedia && ( + <EmbedMedia embed={embed} height={height} /> + )} + </div> + {!largeMedia && ( + <div> + <EmbedMedia + embed={embed} + width={ + height * + ((embed.image?.width ?? 0) / + (embed.image?.height ?? 0)) + } + height={height} + /> + </div> + )} + </div> + ); + } + case "Image": { + return ( + <img + className={classNames(styles.embed, styles.image)} + style={calculateSize(embed.width, embed.height)} + src={proxyImage(embed.url)} + type="text/html" + frameBorder="0" + onClick={() => openScreen({ id: "image_viewer", embed })} + onMouseDown={(ev) => + ev.button === 1 && window.open(embed.url, "_blank") + } + /> + ); + } + default: + return null; + } } diff --git a/src/components/common/messaging/embed/EmbedMedia.tsx b/src/components/common/messaging/embed/EmbedMedia.tsx index 70c045798e901719e3f045d8188296e40825bead..b97922d35d02eba7df29d21f7f692e138be6dbc5 100644 --- a/src/components/common/messaging/embed/EmbedMedia.tsx +++ b/src/components/common/messaging/embed/EmbedMedia.tsx @@ -1,78 +1,100 @@ -import styles from './Embed.module.scss'; import { Embed } from "revolt.js/dist/api/objects"; -import { useIntermediate } from '../../../../context/intermediate/Intermediate'; + +import styles from "./Embed.module.scss"; + +import { useIntermediate } from "../../../../context/intermediate/Intermediate"; interface Props { - embed: Embed; - width?: number; - height: number; + embed: Embed; + width?: number; + height: number; } export default function EmbedMedia({ embed, width, height }: Props) { - // ! FIXME: temp code - // ! add proxy function to client - function proxyImage(url: string) { - return 'https://jan.revolt.chat/proxy?url=' + encodeURIComponent(url); - } + // ! FIXME: temp code + // ! add proxy function to client + function proxyImage(url: string) { + return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url); + } - if (embed.type !== 'Website') return null; - const { openScreen } = useIntermediate(); + if (embed.type !== "Website") return null; + const { openScreen } = useIntermediate(); - switch (embed.special?.type) { - case 'YouTube': return ( - <iframe - src={`https://www.youtube-nocookie.com/embed/${embed.special.id}?modestbranding=1`} - allowFullScreen - style={{ height }} /> - ) - case 'Twitch': return ( - <iframe - src={`https://player.twitch.tv/?${embed.special.content_type.toLowerCase()}=${embed.special.id}&parent=${window.location.hostname}&autoplay=false`} - frameBorder="0" - allowFullScreen - scrolling="no" - style={{ height, }} /> - ) - case 'Spotify': return ( - <iframe - src={`https://open.spotify.com/embed/${embed.special.content_type}/${embed.special.id}`} - frameBorder="0" - allowFullScreen - allowTransparency - style={{ height }} /> - ) - case 'Soundcloud': return ( - <iframe - src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(embed.url!)}&color=%23FF7F50&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true`} - frameBorder="0" - scrolling="no" - style={{ height }} /> - ) - case 'Bandcamp': { - return <iframe - src={`https://bandcamp.com/EmbeddedPlayer/${embed.special.content_type.toLowerCase()}=${embed.special.id}/size=large/bgcol=181a1b/linkcol=056cc4/tracklist=false/transparent=true/`} - seamless - style={{ height }} />; - } - default: { - if (embed.image) { - let url = embed.image.url; - return ( - <img - className={styles.image} - src={proxyImage(url)} - style={{ width, height }} - onClick={() => - openScreen({ id: "image_viewer", embed: embed.image }) - } - onMouseDown={ev => - ev.button === 1 && - window.open(url, "_blank") - } /> - ); - } - } - } + switch (embed.special?.type) { + case "YouTube": + return ( + <iframe + src={`https://www.youtube-nocookie.com/embed/${embed.special.id}?modestbranding=1`} + allowFullScreen + style={{ height }} + /> + ); + case "Twitch": + return ( + <iframe + src={`https://player.twitch.tv/?${embed.special.content_type.toLowerCase()}=${ + embed.special.id + }&parent=${window.location.hostname}&autoplay=false`} + frameBorder="0" + allowFullScreen + scrolling="no" + style={{ height }} + /> + ); + case "Spotify": + return ( + <iframe + src={`https://open.spotify.com/embed/${embed.special.content_type}/${embed.special.id}`} + frameBorder="0" + allowFullScreen + allowTransparency + style={{ height }} + /> + ); + case "Soundcloud": + return ( + <iframe + src={`https://w.soundcloud.com/player/?url=${encodeURIComponent( + embed.url!, + )}&color=%23FF7F50&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true`} + frameBorder="0" + scrolling="no" + style={{ height }} + /> + ); + case "Bandcamp": { + return ( + <iframe + src={`https://bandcamp.com/EmbeddedPlayer/${embed.special.content_type.toLowerCase()}=${ + embed.special.id + }/size=large/bgcol=181a1b/linkcol=056cc4/tracklist=false/transparent=true/`} + seamless + style={{ height }} + /> + ); + } + default: { + if (embed.image) { + let url = embed.image.url; + return ( + <img + className={styles.image} + src={proxyImage(url)} + style={{ width, height }} + onClick={() => + openScreen({ + id: "image_viewer", + embed: embed.image, + }) + } + onMouseDown={(ev) => + ev.button === 1 && window.open(url, "_blank") + } + /> + ); + } + } + } - return null; + return null; } diff --git a/src/components/common/messaging/embed/EmbedMediaActions.tsx b/src/components/common/messaging/embed/EmbedMediaActions.tsx index ab3d714428dbcf8016a17298b9989adfc0079da9..1f7dd4c79eaf3809e8a76ba83d7deda7efa3c43e 100644 --- a/src/components/common/messaging/embed/EmbedMediaActions.tsx +++ b/src/components/common/messaging/embed/EmbedMediaActions.tsx @@ -1,26 +1,30 @@ -import styles from './Embed.module.scss'; -import IconButton from '../../../ui/IconButton'; -import { LinkExternal } from '@styled-icons/boxicons-regular'; +import { LinkExternal } from "@styled-icons/boxicons-regular"; import { EmbedImage } from "revolt.js/dist/api/objects"; +import styles from "./Embed.module.scss"; + +import IconButton from "../../../ui/IconButton"; + interface Props { - embed: EmbedImage; + embed: EmbedImage; } export default function EmbedMediaActions({ embed }: Props) { - const filename = embed.url.split('/').pop(); + const filename = embed.url.split("/").pop(); - return ( - <div className={styles.actions}> - <div className={styles.info}> - <span className={styles.filename}>{filename}</span> - <span className={styles.filesize}>{embed.width + 'x' + embed.height}</span> - </div> - <a href={embed.url} target="_blank"> - <IconButton> - <LinkExternal size={24} /> - </IconButton> - </a> - </div> - ) + return ( + <div className={styles.actions}> + <div className={styles.info}> + <span className={styles.filename}>{filename}</span> + <span className={styles.filesize}> + {embed.width + "x" + embed.height} + </span> + </div> + <a href={embed.url} target="_blank"> + <IconButton> + <LinkExternal size={24} /> + </IconButton> + </a> + </div> + ); } diff --git a/src/components/common/user/UserCheckbox.tsx b/src/components/common/user/UserCheckbox.tsx index 05cb69f71eab4b8cdcbafef8fd2ec952e48b3a77..7e204dd9919b1ad958a2520c573235e08d71073c 100644 --- a/src/components/common/user/UserCheckbox.tsx +++ b/src/components/common/user/UserCheckbox.tsx @@ -1,14 +1,16 @@ import { User } from "revolt.js"; -import UserIcon from "./UserIcon"; + import Checkbox, { CheckboxProps } from "../../ui/Checkbox"; +import UserIcon from "./UserIcon"; + type UserProps = Omit<CheckboxProps, "children"> & { user: User }; export default function UserCheckbox({ user, ...props }: UserProps) { - return ( - <Checkbox {...props}> - <UserIcon target={user} size={32} /> - {user.username} - </Checkbox> - ); + return ( + <Checkbox {...props}> + <UserIcon target={user} size={32} /> + {user.username} + </Checkbox> + ); } diff --git a/src/components/common/user/UserHeader.tsx b/src/components/common/user/UserHeader.tsx index d234340e42d1158bb4df60e126846a117827b58e..9783b3a3c02a6118a65fee47e5da6609f1671800 100644 --- a/src/components/common/user/UserHeader.tsx +++ b/src/components/common/user/UserHeader.tsx @@ -1,75 +1,84 @@ -import Tooltip from "../Tooltip"; +import { Cog } from "@styled-icons/boxicons-solid"; +import { Link } from "react-router-dom"; import { User } from "revolt.js"; -import UserIcon from "./UserIcon"; -import { Text } from "preact-i18n"; -import Header from "../../ui/Header"; -import UserStatus from './UserStatus'; import styled from "styled-components"; -import { Localizer } from 'preact-i18n'; -import { Link } from "react-router-dom"; -import IconButton from "../../ui/IconButton"; -import { Cog } from "@styled-icons/boxicons-solid"; + import { openContextMenu } from "preact-context-menu"; +import { Text } from "preact-i18n"; +import { Localizer } from "preact-i18n"; + import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; + import { useIntermediate } from "../../../context/intermediate/Intermediate"; +import Header from "../../ui/Header"; +import IconButton from "../../ui/IconButton"; + +import Tooltip from "../Tooltip"; +import UserIcon from "./UserIcon"; +import UserStatus from "./UserStatus"; + const HeaderBase = styled.div` - gap: 0; - flex-grow: 1; - min-width: 0; - display: flex; - flex-direction: column; - - * { - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } + gap: 0; + flex-grow: 1; + min-width: 0; + display: flex; + flex-direction: column; + + * { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } - .username { - cursor: pointer; - font-size: 16px; - font-weight: 600; - } + .username { + cursor: pointer; + font-size: 16px; + font-weight: 600; + } - .status { - cursor: pointer; - font-size: 12px; - margin-top: -2px; - } + .status { + cursor: pointer; + font-size: 12px; + margin-top: -2px; + } `; interface Props { - user: User + user: User; } export default function UserHeader({ user }: Props) { - const { writeClipboard } = useIntermediate(); + const { writeClipboard } = useIntermediate(); - return ( - <Header borders placement="secondary"> - <HeaderBase> - <Localizer> - <Tooltip content={<Text id="app.special.copy_username" />}> - <span className="username" - onClick={() => writeClipboard(user.username)}> - @{user.username} - </span> - </Tooltip> - </Localizer> - <span className="status" - onClick={() => openContextMenu("Status")}> - <UserStatus user={user} /> - </span> - </HeaderBase> - { !isTouchscreenDevice && <div className="actions"> - <Link to="/settings"> - <IconButton> - <Cog size={24} /> - </IconButton> - </Link> - </div> } - </Header> - ) + return ( + <Header borders placement="secondary"> + <HeaderBase> + <Localizer> + <Tooltip content={<Text id="app.special.copy_username" />}> + <span + className="username" + onClick={() => writeClipboard(user.username)}> + @{user.username} + </span> + </Tooltip> + </Localizer> + <span + className="status" + onClick={() => openContextMenu("Status")}> + <UserStatus user={user} /> + </span> + </HeaderBase> + {!isTouchscreenDevice && ( + <div className="actions"> + <Link to="/settings"> + <IconButton> + <Cog size={24} /> + </IconButton> + </Link> + </div> + )} + </Header> + ); } diff --git a/src/components/common/user/UserIcon.tsx b/src/components/common/user/UserIcon.tsx index 724f042ee7eb13688cfd98d470b67e51b2b4e2cf..d1c2fb9c3f95a5625e1e1ce1a2ac7a80f3b1387f 100644 --- a/src/components/common/user/UserIcon.tsx +++ b/src/components/common/user/UserIcon.tsx @@ -1,93 +1,104 @@ -import { User } from "revolt.js"; -import { useContext } from "preact/hooks"; import { MicrophoneOff } from "@styled-icons/boxicons-regular"; -import styled, { css } from "styled-components"; +import { User } from "revolt.js"; import { Users } from "revolt.js/dist/api/objects"; +import styled, { css } from "styled-components"; + +import { useContext } from "preact/hooks"; + import { ThemeContext } from "../../../context/Theme"; -import IconBase, { IconBaseProps } from "../IconBase"; import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import IconBase, { IconBaseProps } from "../IconBase"; +import fallback from "../assets/user.png"; + type VoiceStatus = "muted"; interface Props extends IconBaseProps<User> { - mask?: string; - status?: boolean; - voice?: VoiceStatus; + mask?: string; + status?: boolean; + voice?: VoiceStatus; } export function useStatusColour(user?: User) { - const theme = useContext(ThemeContext); + const theme = useContext(ThemeContext); - return ( - user?.online && - user?.status?.presence !== Users.Presence.Invisible - ? user?.status?.presence === Users.Presence.Idle - ? theme["status-away"] - : user?.status?.presence === - Users.Presence.Busy - ? theme["status-busy"] - : theme["status-online"] - : theme["status-invisible"] - ); + return user?.online && user?.status?.presence !== Users.Presence.Invisible + ? user?.status?.presence === Users.Presence.Idle + ? theme["status-away"] + : user?.status?.presence === Users.Presence.Busy + ? theme["status-busy"] + : theme["status-online"] + : theme["status-invisible"]; } const VoiceIndicator = styled.div<{ status: VoiceStatus }>` - width: 10px; - height: 10px; - border-radius: 50%; + width: 10px; + height: 10px; + border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; + display: flex; + align-items: center; + justify-content: center; - svg { - stroke: white; - } + svg { + stroke: white; + } - ${ props => props.status === 'muted' && css` - background: var(--error); - ` } + ${(props) => + props.status === "muted" && + css` + background: var(--error); + `} `; -import fallback from '../assets/user.png'; - -export default function UserIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>) { - const client = useContext(AppContext); +export default function UserIcon( + props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>, +) { + const client = useContext(AppContext); - const { target, attachment, size, voice, status, animate, mask, children, as, ...svgProps } = props; - const iconURL = client.generateFileURL(target?.avatar ?? attachment, { max_side: 256 }, animate) - ?? (target ? client.users.getDefaultAvatarURL(target._id) : fallback); + const { + target, + attachment, + size, + voice, + status, + animate, + mask, + children, + as, + ...svgProps + } = props; + const iconURL = + client.generateFileURL( + target?.avatar ?? attachment, + { max_side: 256 }, + animate, + ) ?? (target ? client.users.getDefaultAvatarURL(target._id) : fallback); - return ( - <IconBase {...svgProps} - width={size} - height={size} - aria-hidden="true" - viewBox="0 0 32 32"> - <foreignObject x="0" y="0" width="32" height="32" mask={mask ?? (status ? "url(#user)" : undefined)}> - { - <img src={iconURL} - draggable={false} /> - } - </foreignObject> - {props.status && ( - <circle - cx="27" - cy="27" - r="5" - fill={useStatusColour(target)} - /> - )} - {props.voice && ( - <foreignObject - x="22" - y="22" - width="10" - height="10"> - <VoiceIndicator status={props.voice}> - {props.voice === "muted" && <MicrophoneOff size={6} />} - </VoiceIndicator> - </foreignObject> - )} - </IconBase> - ); + return ( + <IconBase + {...svgProps} + width={size} + height={size} + aria-hidden="true" + viewBox="0 0 32 32"> + <foreignObject + x="0" + y="0" + width="32" + height="32" + mask={mask ?? (status ? "url(#user)" : undefined)}> + {<img src={iconURL} draggable={false} />} + </foreignObject> + {props.status && ( + <circle cx="27" cy="27" r="5" fill={useStatusColour(target)} /> + )} + {props.voice && ( + <foreignObject x="22" y="22" width="10" height="10"> + <VoiceIndicator status={props.voice}> + {props.voice === "muted" && <MicrophoneOff size={6} />} + </VoiceIndicator> + </foreignObject> + )} + </IconBase> + ); } diff --git a/src/components/common/user/UserShort.tsx b/src/components/common/user/UserShort.tsx index f0cec4eda4b0337db9fc4fa3825ccac770b3a054..3c2b4aa00e465ce6b8ae31b33edaed8a63463985 100644 --- a/src/components/common/user/UserShort.tsx +++ b/src/components/common/user/UserShort.tsx @@ -1,14 +1,31 @@ import { User } from "revolt.js"; -import UserIcon from "./UserIcon"; + import { Text } from "preact-i18n"; -export function Username({ user, ...otherProps }: { user?: User } & JSX.HTMLAttributes<HTMLElement>) { - return <span {...otherProps}>{ user?.username ?? <Text id="app.main.channel.unknown_user" /> }</span>; +import UserIcon from "./UserIcon"; + +export function Username({ + user, + ...otherProps +}: { user?: User } & JSX.HTMLAttributes<HTMLElement>) { + return ( + <span {...otherProps}> + {user?.username ?? <Text id="app.main.channel.unknown_user" />} + </span> + ); } -export default function UserShort({ user, size }: { user?: User, size?: number }) { - return <> - <UserIcon size={size ?? 24} target={user} /> - <Username user={user} /> - </>; -} \ No newline at end of file +export default function UserShort({ + user, + size, +}: { + user?: User; + size?: number; +}) { + return ( + <> + <UserIcon size={size ?? 24} target={user} /> + <Username user={user} /> + </> + ); +} diff --git a/src/components/common/user/UserStatus.tsx b/src/components/common/user/UserStatus.tsx index 5a525517ecbc4878d132c5c93e02143537639abe..d4b3f60cc3ef20a728b0e904a12c10d5bb45c116 100644 --- a/src/components/common/user/UserStatus.tsx +++ b/src/components/common/user/UserStatus.tsx @@ -1,31 +1,32 @@ import { User } from "revolt.js"; -import { Text } from "preact-i18n"; import { Users } from "revolt.js/dist/api/objects"; +import { Text } from "preact-i18n"; + interface Props { - user: User; + user: User; } export default function UserStatus({ user }: Props) { - if (user.online) { - if (user.status?.text) { - return <>{user.status?.text}</>; - } + if (user.online) { + if (user.status?.text) { + return <>{user.status?.text}</>; + } - if (user.status?.presence === Users.Presence.Busy) { - return <Text id="app.status.busy" />; - } + if (user.status?.presence === Users.Presence.Busy) { + return <Text id="app.status.busy" />; + } - if (user.status?.presence === Users.Presence.Idle) { - return <Text id="app.status.idle" />; - } + if (user.status?.presence === Users.Presence.Idle) { + return <Text id="app.status.idle" />; + } - if (user.status?.presence === Users.Presence.Invisible) { - return <Text id="app.status.offline" />; - } + if (user.status?.presence === Users.Presence.Invisible) { + return <Text id="app.status.offline" />; + } - return <Text id="app.status.online" />; - } + return <Text id="app.status.online" />; + } - return <Text id="app.status.offline" />; + return <Text id="app.status.offline" />; } diff --git a/src/components/markdown/Markdown.tsx b/src/components/markdown/Markdown.tsx index 1dffb1c07b61cb3ecee05003482494b3e3a04054..fbd274d087310380240300e2a371b49a9da32cb8 100644 --- a/src/components/markdown/Markdown.tsx +++ b/src/components/markdown/Markdown.tsx @@ -1,17 +1,17 @@ import { Suspense, lazy } from "preact/compat"; -const Renderer = lazy(() => import('./Renderer')); +const Renderer = lazy(() => import("./Renderer")); export interface MarkdownProps { - content?: string; - disallowBigEmoji?: boolean; + content?: string; + disallowBigEmoji?: boolean; } export default function Markdown(props: MarkdownProps) { - return ( - // @ts-expect-error - <Suspense fallback={props.content}> - <Renderer {...props} /> - </Suspense> - ) + return ( + // @ts-expect-error + <Suspense fallback={props.content}> + <Renderer {...props} /> + </Suspense> + ); } diff --git a/src/components/markdown/Renderer.tsx b/src/components/markdown/Renderer.tsx index 61575a8650f59c71ce6897bd9e8a9ac2d7a34f83..a1dafb90a1268db572bb74023ecc35fc28ff1c35 100644 --- a/src/components/markdown/Renderer.tsx +++ b/src/components/markdown/Renderer.tsx @@ -1,187 +1,192 @@ +import MarkdownKatex from "@traptitech/markdown-it-katex"; +import MarkdownSpoilers from "@traptitech/markdown-it-spoiler"; +import "katex/dist/katex.min.css"; import MarkdownIt from "markdown-it"; +// @ts-ignore +import MarkdownEmoji from "markdown-it-emoji/dist/markdown-it-emoji-bare"; +// @ts-ignore +import MarkdownSub from "markdown-it-sub"; +// @ts-ignore +import MarkdownSup from "markdown-it-sup"; +import Prism from "prismjs"; +import "prismjs/themes/prism-tomorrow.css"; import { RE_MENTIONS } from "revolt.js"; -import { useContext } from "preact/hooks"; -import { MarkdownProps } from "./Markdown"; + import styles from "./Markdown.module.scss"; -import { generateEmoji } from "../common/Emoji"; +import { useContext } from "preact/hooks"; + import { internalEmit } from "../../lib/eventEmitter"; -import { emojiDictionary } from "../../assets/emojis"; -import { AppContext } from "../../context/revoltjs/RevoltClient"; -import Prism from "prismjs"; -import "katex/dist/katex.min.css"; -import "prismjs/themes/prism-tomorrow.css"; +import { AppContext } from "../../context/revoltjs/RevoltClient"; -import MarkdownKatex from "@traptitech/markdown-it-katex"; -import MarkdownSpoilers from "@traptitech/markdown-it-spoiler"; +import { generateEmoji } from "../common/Emoji"; -// @ts-ignore -import MarkdownEmoji from "markdown-it-emoji/dist/markdown-it-emoji-bare"; -// @ts-ignore -import MarkdownSup from "markdown-it-sup"; -// @ts-ignore -import MarkdownSub from "markdown-it-sub"; +import { emojiDictionary } from "../../assets/emojis"; +import { MarkdownProps } from "./Markdown"; // TODO: global.d.ts file for defining globals declare global { - interface Window { - copycode: (element: HTMLDivElement) => void - } + interface Window { + copycode: (element: HTMLDivElement) => void; + } } - // Handler for code block copy. if (typeof window !== "undefined") { - window.copycode = function(element: HTMLDivElement) { - try { - let code = element.parentElement?.parentElement?.children[1]; - if (code) { - navigator.clipboard.writeText(code.textContent?.trim() ?? ''); - } - } catch (e) {} - }; + window.copycode = function (element: HTMLDivElement) { + try { + let code = element.parentElement?.parentElement?.children[1]; + if (code) { + navigator.clipboard.writeText(code.textContent?.trim() ?? ""); + } + } catch (e) {} + }; } export const md: MarkdownIt = MarkdownIt({ - breaks: true, - linkify: true, - highlight: (str, lang) => { - let v = Prism.languages[lang]; - if (v) { - let out = Prism.highlight(str, v, lang); - return `<pre class="code"><div class="lang"><div onclick="copycode(this)">${lang}</div></div><code class="language-${lang}">${out}</code></pre>`; - } - - return `<pre class="code"><code>${md.utils.escapeHtml(str)}</code></pre>`; - } + breaks: true, + linkify: true, + highlight: (str, lang) => { + let v = Prism.languages[lang]; + if (v) { + let out = Prism.highlight(str, v, lang); + return `<pre class="code"><div class="lang"><div onclick="copycode(this)">${lang}</div></div><code class="language-${lang}">${out}</code></pre>`; + } + + return `<pre class="code"><code>${md.utils.escapeHtml( + str, + )}</code></pre>`; + }, }) -.disable("image") -.use(MarkdownEmoji, { defs: emojiDictionary }) -.use(MarkdownSpoilers) -.use(MarkdownSup) -.use(MarkdownSub) -.use(MarkdownKatex, { - throwOnError: false, - maxExpand: 0 -}); + .disable("image") + .use(MarkdownEmoji, { defs: emojiDictionary }) + .use(MarkdownSpoilers) + .use(MarkdownSup) + .use(MarkdownSub) + .use(MarkdownKatex, { + throwOnError: false, + maxExpand: 0, + }); // ? Force links to open _blank. // From: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer const defaultRender = - md.renderer.rules.link_open || - function(tokens, idx, options, _env, self) { - return self.renderToken(tokens, idx, options); - }; + md.renderer.rules.link_open || + function (tokens, idx, options, _env, self) { + return self.renderToken(tokens, idx, options); + }; // TODO: global.d.ts file for defining globals declare global { - interface Window { - internalHandleURL: (element: HTMLAnchorElement) => void - } + interface Window { + internalHandleURL: (element: HTMLAnchorElement) => void; + } } // Handler for internal links, pushes events to React using magic. if (typeof window !== "undefined") { - window.internalHandleURL = function(element: HTMLAnchorElement) { - const url = new URL(element.href, location.href); - const pathname = url.pathname; - - if (pathname.startsWith("/@")) { - internalEmit("Intermediate", "openProfile", pathname.substr(2)); - } else { - internalEmit("Intermediate", "navigate", pathname); - } - }; + window.internalHandleURL = function (element: HTMLAnchorElement) { + const url = new URL(element.href, location.href); + const pathname = url.pathname; + + if (pathname.startsWith("/@")) { + internalEmit("Intermediate", "openProfile", pathname.substr(2)); + } else { + internalEmit("Intermediate", "navigate", pathname); + } + }; } -md.renderer.rules.link_open = function(tokens, idx, options, env, self) { - let internal; - const hIndex = tokens[idx].attrIndex("href"); - if (hIndex >= 0) { - try { - // For internal links, we should use our own handler to use react-router history. - // @ts-ignore - const href = tokens[idx].attrs[hIndex][1]; - const url = new URL(href, location.href); - - if (url.hostname === location.hostname) { - internal = true; - // I'm sorry. - tokens[idx].attrPush([ - "onclick", - "internalHandleURL(this); return false" - ]); - - if (url.pathname.startsWith("/@")) { - tokens[idx].attrPush(["data-type", "mention"]); - } - } - } catch (err) { - // Ignore the error, treat as normal link. - } - } - - if (!internal) { - // Add target=_blank for external links. - const aIndex = tokens[idx].attrIndex("target"); - - if (aIndex < 0) { - tokens[idx].attrPush(["target", "_blank"]); - } else { - try { - // @ts-ignore - tokens[idx].attrs[aIndex][1] = "_blank"; - } catch (_) {} - } - } - - return defaultRender(tokens, idx, options, env, self); +md.renderer.rules.link_open = function (tokens, idx, options, env, self) { + let internal; + const hIndex = tokens[idx].attrIndex("href"); + if (hIndex >= 0) { + try { + // For internal links, we should use our own handler to use react-router history. + // @ts-ignore + const href = tokens[idx].attrs[hIndex][1]; + const url = new URL(href, location.href); + + if (url.hostname === location.hostname) { + internal = true; + // I'm sorry. + tokens[idx].attrPush([ + "onclick", + "internalHandleURL(this); return false", + ]); + + if (url.pathname.startsWith("/@")) { + tokens[idx].attrPush(["data-type", "mention"]); + } + } + } catch (err) { + // Ignore the error, treat as normal link. + } + } + + if (!internal) { + // Add target=_blank for external links. + const aIndex = tokens[idx].attrIndex("target"); + + if (aIndex < 0) { + tokens[idx].attrPush(["target", "_blank"]); + } else { + try { + // @ts-ignore + tokens[idx].attrs[aIndex][1] = "_blank"; + } catch (_) {} + } + } + + return defaultRender(tokens, idx, options, env, self); }; -md.renderer.rules.emoji = function(token, idx) { - return generateEmoji(token[idx].content); +md.renderer.rules.emoji = function (token, idx) { + return generateEmoji(token[idx].content); }; const RE_TWEMOJI = /:(\w+):/g; export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) { - const client = useContext(AppContext); - if (typeof content === "undefined") return null; - if (content.length === 0) return null; - - // We replace the message with the mention at the time of render. - // We don't care if the mention changes. - let newContent = content.replace( - RE_MENTIONS, - (sub: string, ...args: any[]) => { - const id = args[0], - user = client.users.get(id); - - if (user) { - return `[@${user.username}](/@${id})`; - } - - return sub; - } - ); - - const useLargeEmojis = disallowBigEmoji ? false : content.replace(RE_TWEMOJI, '').trim().length === 0; - - return ( - <span - className={styles.markdown} - dangerouslySetInnerHTML={{ - __html: md.render(newContent) - }} - data-large-emojis={useLargeEmojis} - onClick={ev => { - if (ev.target) { - let element = ev.currentTarget; - if (element.classList.contains("spoiler")) { - element.classList.add("shown"); - } - } - }} - /> - ); + const client = useContext(AppContext); + if (typeof content === "undefined") return null; + if (content.length === 0) return null; + + // We replace the message with the mention at the time of render. + // We don't care if the mention changes. + let newContent = content.replace( + RE_MENTIONS, + (sub: string, ...args: any[]) => { + const id = args[0], + user = client.users.get(id); + + if (user) { + return `[@${user.username}](/@${id})`; + } + + return sub; + }, + ); + + const useLargeEmojis = disallowBigEmoji + ? false + : content.replace(RE_TWEMOJI, "").trim().length === 0; + + return ( + <span + className={styles.markdown} + dangerouslySetInnerHTML={{ + __html: md.render(newContent), + }} + data-large-emojis={useLargeEmojis} + onClick={(ev) => { + if (ev.target) { + let element = ev.currentTarget; + if (element.classList.contains("spoiler")) { + element.classList.add("shown"); + } + } + }} + /> + ); } diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx index f2d2482ce6ff038323482b5cb3f81c1678769dbf..fd4629d4d6822b11135b8ad7f22a579967231e18 100644 --- a/src/components/navigation/BottomNavigation.tsx +++ b/src/components/navigation/BottomNavigation.tsx @@ -1,88 +1,96 @@ -import IconButton from "../ui/IconButton"; -import UserIcon from "../common/user/UserIcon"; -import styled, { css } from "styled-components"; -import { useSelf } from "../../context/revoltjs/hooks"; +import { Message, Group } from "@styled-icons/boxicons-solid"; import { useHistory, useLocation } from "react-router"; +import styled, { css } from "styled-components"; + import ConditionalLink from "../../lib/ConditionalLink"; -import { Message, Group } from "@styled-icons/boxicons-solid"; -import { LastOpened } from "../../redux/reducers/last_opened"; + import { connectState } from "../../redux/connector"; +import { LastOpened } from "../../redux/reducers/last_opened"; + +import { useSelf } from "../../context/revoltjs/hooks"; + +import UserIcon from "../common/user/UserIcon"; +import IconButton from "../ui/IconButton"; const NavigationBase = styled.div` - z-index: 100; - height: 50px; - display: flex; - background: var(--secondary-background); + z-index: 100; + height: 50px; + display: flex; + background: var(--secondary-background); `; const Button = styled.a<{ active: boolean }>` - flex: 1; + flex: 1; - > a, > div, > a > div { - width: 100%; - height: 100%; - } + > a, + > div, + > a > div { + width: 100%; + height: 100%; + } - ${ props => props.active && css` - background: var(--hover); - ` } + ${(props) => + props.active && + css` + background: var(--hover); + `} `; interface Props { - lastOpened: LastOpened + lastOpened: LastOpened; } export function BottomNavigation({ lastOpened }: Props) { - const user = useSelf(); - const history = useHistory(); - const path = useLocation().pathname; + const user = useSelf(); + const history = useHistory(); + const path = useLocation().pathname; + + const channel_id = lastOpened["home"]; - const channel_id = lastOpened['home']; + const friendsActive = path.startsWith("/friends"); + const settingsActive = path.startsWith("/settings"); + const homeActive = !(friendsActive || settingsActive); - const friendsActive = path.startsWith("/friends"); - const settingsActive = path.startsWith("/settings"); - const homeActive = !(friendsActive || settingsActive); + return ( + <NavigationBase> + <Button active={homeActive}> + <IconButton + onClick={() => { + if (settingsActive) { + if (history.length > 0) { + history.goBack(); + } + } - return ( - <NavigationBase> - <Button active={homeActive}> - <IconButton - onClick={() => { - if (settingsActive) { - if (history.length > 0) { - history.goBack(); - } - } - - if (channel_id) { - history.push(`/channel/${channel_id}`); - } else { - history.push('/'); - } - }}> - <Message size={24} /> - </IconButton> - </Button> - <Button active={friendsActive}> - <ConditionalLink active={friendsActive} to="/friends"> - <IconButton> - <Group size={25} /> - </IconButton> - </ConditionalLink> - </Button> - <Button active={settingsActive}> - <ConditionalLink active={settingsActive} to="/settings"> - <IconButton> - <UserIcon target={user} size={26} status={true} /> - </IconButton> - </ConditionalLink> - </Button> - </NavigationBase> - ); + if (channel_id) { + history.push(`/channel/${channel_id}`); + } else { + history.push("/"); + } + }}> + <Message size={24} /> + </IconButton> + </Button> + <Button active={friendsActive}> + <ConditionalLink active={friendsActive} to="/friends"> + <IconButton> + <Group size={25} /> + </IconButton> + </ConditionalLink> + </Button> + <Button active={settingsActive}> + <ConditionalLink active={settingsActive} to="/settings"> + <IconButton> + <UserIcon target={user} size={26} status={true} /> + </IconButton> + </ConditionalLink> + </Button> + </NavigationBase> + ); } -export default connectState(BottomNavigation, state => { - return { - lastOpened: state.lastOpened - } +export default connectState(BottomNavigation, (state) => { + return { + lastOpened: state.lastOpened, + }; }); diff --git a/src/components/navigation/LeftSidebar.tsx b/src/components/navigation/LeftSidebar.tsx index 6852a848c9b506463a1320456dcd88a94f02de4f..ccc675d2e30461f683168273c87844bfabc36daf 100644 --- a/src/components/navigation/LeftSidebar.tsx +++ b/src/components/navigation/LeftSidebar.tsx @@ -1,32 +1,32 @@ import { Route, Switch } from "react-router"; -import SidebarBase from "./SidebarBase"; +import SidebarBase from "./SidebarBase"; +import HomeSidebar from "./left/HomeSidebar"; import ServerListSidebar from "./left/ServerListSidebar"; import ServerSidebar from "./left/ServerSidebar"; -import HomeSidebar from "./left/HomeSidebar"; export default function LeftSidebar() { - return ( - <SidebarBase> - <Switch> - <Route path="/settings" /> - <Route path="/server/:server/channel/:channel"> - <ServerListSidebar /> - <ServerSidebar /> - </Route> - <Route path="/server/:server"> - <ServerListSidebar /> - <ServerSidebar /> - </Route> - <Route path="/channel/:channel"> - <ServerListSidebar /> - <HomeSidebar /> - </Route> - <Route path="/"> - <ServerListSidebar /> - <HomeSidebar /> - </Route> - </Switch> - </SidebarBase> - ); -}; + return ( + <SidebarBase> + <Switch> + <Route path="/settings" /> + <Route path="/server/:server/channel/:channel"> + <ServerListSidebar /> + <ServerSidebar /> + </Route> + <Route path="/server/:server"> + <ServerListSidebar /> + <ServerSidebar /> + </Route> + <Route path="/channel/:channel"> + <ServerListSidebar /> + <HomeSidebar /> + </Route> + <Route path="/"> + <ServerListSidebar /> + <HomeSidebar /> + </Route> + </Switch> + </SidebarBase> + ); +} diff --git a/src/components/navigation/RightSidebar.tsx b/src/components/navigation/RightSidebar.tsx index 45371a8fa4eea465cc45c2dc74294e17606eaa1f..b1d6adfa826670321e12baeaccb899f57c7f4dd1 100644 --- a/src/components/navigation/RightSidebar.tsx +++ b/src/components/navigation/RightSidebar.tsx @@ -1,19 +1,19 @@ import { Route, Switch } from "react-router"; -import SidebarBase from "./SidebarBase"; +import SidebarBase from "./SidebarBase"; import MemberSidebar from "./right/MemberSidebar"; export default function RightSidebar() { - return ( - <SidebarBase> - <Switch> - <Route path="/server/:server/channel/:channel"> - <MemberSidebar /> - </Route> - <Route path="/channel/:channel"> - <MemberSidebar /> - </Route> - </Switch> - </SidebarBase> - ); -}; + return ( + <SidebarBase> + <Switch> + <Route path="/server/:server/channel/:channel"> + <MemberSidebar /> + </Route> + <Route path="/channel/:channel"> + <MemberSidebar /> + </Route> + </Switch> + </SidebarBase> + ); +} diff --git a/src/components/navigation/SidebarBase.tsx b/src/components/navigation/SidebarBase.tsx index 00c6f66f8027391bc733e68d349a58afbf273a04..8d5cd607f5c746c524d4c55b61f070c0cde94d68 100644 --- a/src/components/navigation/SidebarBase.tsx +++ b/src/components/navigation/SidebarBase.tsx @@ -1,34 +1,38 @@ import styled, { css } from "styled-components"; + import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; export default styled.div` - height: 100%; - display: flex; - user-select: none; - flex-direction: row; - align-items: stretch; + height: 100%; + display: flex; + user-select: none; + flex-direction: row; + align-items: stretch; `; export const GenericSidebarBase = styled.div<{ padding?: boolean }>` - height: 100%; - width: 240px; - display: flex; - flex-shrink: 0; - flex-direction: column; - background: var(--secondary-background); - border-end-start-radius: 8px; + height: 100%; + width: 240px; + display: flex; + flex-shrink: 0; + flex-direction: column; + background: var(--secondary-background); + border-end-start-radius: 8px; - ${ props => props.padding && isTouchscreenDevice && css` - padding-bottom: 50px; - ` } + ${(props) => + props.padding && + isTouchscreenDevice && + css` + padding-bottom: 50px; + `} `; export const GenericSidebarList = styled.div` - padding: 6px; - flex-grow: 1; - overflow-y: scroll; + padding: 6px; + flex-grow: 1; + overflow-y: scroll; - > img { - width: 100%; - } + > img { + width: 100%; + } `; diff --git a/src/components/navigation/items/ButtonItem.tsx b/src/components/navigation/items/ButtonItem.tsx index 176f5f809f0a63ef9495c3a789094ae865f2a49b..5da41cf5e9eb78868342cae83db08af87c50f4bb 100644 --- a/src/components/navigation/items/ButtonItem.tsx +++ b/src/components/navigation/items/ButtonItem.tsx @@ -1,158 +1,223 @@ -import classNames from 'classnames'; -import styles from "./Item.module.scss"; -import Tooltip from '../../common/Tooltip'; -import IconButton from '../../ui/IconButton'; -import { Localizer, Text } from "preact-i18n"; import { X, Crown } from "@styled-icons/boxicons-regular"; -import { Children } from "../../../types/Preact"; -import UserIcon from '../../common/user/UserIcon'; -import ChannelIcon from '../../common/ChannelIcon'; -import UserStatus from '../../common/user/UserStatus'; -import { attachContextMenu } from 'preact-context-menu'; import { Channels, Users } from "revolt.js/dist/api/objects"; + +import styles from "./Item.module.scss"; +import classNames from "classnames"; +import { attachContextMenu } from "preact-context-menu"; +import { Localizer, Text } from "preact-i18n"; + import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; -import { useIntermediate } from '../../../context/intermediate/Intermediate'; -import { stopPropagation } from '../../../lib/stopPropagation'; +import { stopPropagation } from "../../../lib/stopPropagation"; -type CommonProps = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as'> & { - active?: boolean - alert?: 'unread' | 'mention' - alertCount?: number -} +import { useIntermediate } from "../../../context/intermediate/Intermediate"; + +import ChannelIcon from "../../common/ChannelIcon"; +import Tooltip from "../../common/Tooltip"; +import UserIcon from "../../common/user/UserIcon"; +import UserStatus from "../../common/user/UserStatus"; +import IconButton from "../../ui/IconButton"; + +import { Children } from "../../../types/Preact"; + +type CommonProps = Omit< + JSX.HTMLAttributes<HTMLDivElement>, + "children" | "as" +> & { + active?: boolean; + alert?: "unread" | "mention"; + alertCount?: number; +}; type UserProps = CommonProps & { - user: Users.User, - context?: Channels.Channel, - channel?: Channels.DirectMessageChannel -} + user: Users.User; + context?: Channels.Channel; + channel?: Channels.DirectMessageChannel; +}; export function UserButton(props: UserProps) { - const { active, alert, alertCount, user, context, channel, ...divProps } = props; - const { openScreen } = useIntermediate(); - - return ( - <div {...divProps} - className={classNames(styles.item, styles.user)} - data-active={active} - data-alert={typeof alert === 'string'} - data-online={typeof channel !== 'undefined' || (user.online && user.status?.presence !== Users.Presence.Invisible)} - onContextMenu={attachContextMenu('Menu', { - user: user._id, - channel: channel?._id, - unread: alert, - contextualChannel: context?._id - })}> - <UserIcon className={styles.avatar} target={user} size={32} status /> - <div className={styles.name}> - <div>{user.username}</div> - { - <div className={styles.subText}> - { channel?.last_message && alert ? ( - channel.last_message.short - ) : ( - <UserStatus user={user} /> - ) } - </div> - } - </div> - <div className={styles.button}> - { context?.channel_type === "Group" && - context.owner === user._id && ( - <Localizer> - <Tooltip - content={ - <Text id="app.main.groups.owner" /> - } - > - <Crown size={20} /> - </Tooltip> - </Localizer> - )} - {alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>} - { !isTouchscreenDevice && channel && - <IconButton className={styles.icon} - onClick={e => stopPropagation(e) && openScreen({ id: 'special_prompt', type: 'close_dm', target: channel })}> - <X size={24} /> - </IconButton> - } - </div> - </div> - ) + const { active, alert, alertCount, user, context, channel, ...divProps } = + props; + const { openScreen } = useIntermediate(); + + return ( + <div + {...divProps} + className={classNames(styles.item, styles.user)} + data-active={active} + data-alert={typeof alert === "string"} + data-online={ + typeof channel !== "undefined" || + (user.online && + user.status?.presence !== Users.Presence.Invisible) + } + onContextMenu={attachContextMenu("Menu", { + user: user._id, + channel: channel?._id, + unread: alert, + contextualChannel: context?._id, + })}> + <UserIcon + className={styles.avatar} + target={user} + size={32} + status + /> + <div className={styles.name}> + <div>{user.username}</div> + { + <div className={styles.subText}> + {channel?.last_message && alert ? ( + channel.last_message.short + ) : ( + <UserStatus user={user} /> + )} + </div> + } + </div> + <div className={styles.button}> + {context?.channel_type === "Group" && + context.owner === user._id && ( + <Localizer> + <Tooltip + content={<Text id="app.main.groups.owner" />}> + <Crown size={20} /> + </Tooltip> + </Localizer> + )} + {alert && ( + <div className={styles.alert} data-style={alert}> + {alertCount} + </div> + )} + {!isTouchscreenDevice && channel && ( + <IconButton + className={styles.icon} + onClick={(e) => + stopPropagation(e) && + openScreen({ + id: "special_prompt", + type: "close_dm", + target: channel, + }) + }> + <X size={24} /> + </IconButton> + )} + </div> + </div> + ); } type ChannelProps = CommonProps & { - channel: Channels.Channel & { unread?: string }, - user?: Users.User - compact?: boolean -} + channel: Channels.Channel & { unread?: string }; + user?: Users.User; + compact?: boolean; +}; export function ChannelButton(props: ChannelProps) { - const { active, alert, alertCount, channel, user, compact, ...divProps } = props; - - if (channel.channel_type === 'SavedMessages') throw "Invalid channel type."; - if (channel.channel_type === 'DirectMessage') { - if (typeof user === 'undefined') throw "No user provided."; - return <UserButton {...{ active, alert, channel, user }} /> - } - - const { openScreen } = useIntermediate(); - - return ( - <div {...divProps} - data-active={active} - data-alert={typeof alert === 'string'} - aria-label={{}} /*FIXME: ADD ARIA LABEL*/ - className={classNames(styles.item, { [styles.compact]: compact })} - onContextMenu={attachContextMenu('Menu', { channel: channel._id, unread: typeof channel.unread !== 'undefined' })}> - <ChannelIcon className={styles.avatar} target={channel} size={compact ? 24 : 32} /> - <div className={styles.name}> - <div>{channel.name}</div> - { channel.channel_type === 'Group' && - <div className={styles.subText}> - {(channel.last_message && alert) ? ( - channel.last_message.short - ) : ( - <Text - id="quantities.members" - plural={channel.recipients.length} - fields={{ count: channel.recipients.length }} - /> - )} - </div> - } - </div> - <div className={styles.button}> - {alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>} - {!isTouchscreenDevice && channel.channel_type === "Group" && ( - <IconButton - className={styles.icon} - onClick={() => openScreen({ id: 'special_prompt', type: 'leave_group', target: channel })}> - <X size={24} /> - </IconButton> - )} - </div> - </div> - ) + const { active, alert, alertCount, channel, user, compact, ...divProps } = + props; + + if (channel.channel_type === "SavedMessages") throw "Invalid channel type."; + if (channel.channel_type === "DirectMessage") { + if (typeof user === "undefined") throw "No user provided."; + return <UserButton {...{ active, alert, channel, user }} />; + } + + const { openScreen } = useIntermediate(); + + return ( + <div + {...divProps} + data-active={active} + data-alert={typeof alert === "string"} + aria-label={{}} /*FIXME: ADD ARIA LABEL*/ + className={classNames(styles.item, { [styles.compact]: compact })} + onContextMenu={attachContextMenu("Menu", { + channel: channel._id, + unread: typeof channel.unread !== "undefined", + })}> + <ChannelIcon + className={styles.avatar} + target={channel} + size={compact ? 24 : 32} + /> + <div className={styles.name}> + <div>{channel.name}</div> + {channel.channel_type === "Group" && ( + <div className={styles.subText}> + {channel.last_message && alert ? ( + channel.last_message.short + ) : ( + <Text + id="quantities.members" + plural={channel.recipients.length} + fields={{ count: channel.recipients.length }} + /> + )} + </div> + )} + </div> + <div className={styles.button}> + {alert && ( + <div className={styles.alert} data-style={alert}> + {alertCount} + </div> + )} + {!isTouchscreenDevice && channel.channel_type === "Group" && ( + <IconButton + className={styles.icon} + onClick={() => + openScreen({ + id: "special_prompt", + type: "leave_group", + target: channel, + }) + }> + <X size={24} /> + </IconButton> + )} + </div> + </div> + ); } type ButtonProps = CommonProps & { - onClick?: () => void - children?: Children - className?: string - compact?: boolean -} + onClick?: () => void; + children?: Children; + className?: string; + compact?: boolean; +}; export default function ButtonItem(props: ButtonProps) { - const { active, alert, alertCount, onClick, className, children, compact, ...divProps } = props; - - return ( - <div {...divProps} - className={classNames(styles.item, { [styles.compact]: compact, [styles.normal]: !compact }, className)} - onClick={onClick} - data-active={active} - data-alert={typeof alert === 'string'}> - <div className={styles.content}>{ children }</div> - {alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>} - </div> - ) + const { + active, + alert, + alertCount, + onClick, + className, + children, + compact, + ...divProps + } = props; + + return ( + <div + {...divProps} + className={classNames( + styles.item, + { [styles.compact]: compact, [styles.normal]: !compact }, + className, + )} + onClick={onClick} + data-active={active} + data-alert={typeof alert === "string"}> + <div className={styles.content}>{children}</div> + {alert && ( + <div className={styles.alert} data-style={alert}> + {alertCount} + </div> + )} + </div> + ); } diff --git a/src/components/navigation/items/ConnectionStatus.tsx b/src/components/navigation/items/ConnectionStatus.tsx index 7b7a9b08bbfcd3f6c9e0374b110b771172b9f41b..667f2da5e7fd51c23d1a38f3f96805a05130b582 100644 --- a/src/components/navigation/items/ConnectionStatus.tsx +++ b/src/components/navigation/items/ConnectionStatus.tsx @@ -1,35 +1,40 @@ import { Text } from "preact-i18n"; -import Banner from "../../ui/Banner"; import { useContext } from "preact/hooks"; -import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; + +import { + ClientStatus, + StatusContext, +} from "../../../context/revoltjs/RevoltClient"; + +import Banner from "../../ui/Banner"; export default function ConnectionStatus() { - const status = useContext(StatusContext); + const status = useContext(StatusContext); - if (status === ClientStatus.OFFLINE) { - return ( - <Banner> - <Text id="app.special.status.offline" /> - </Banner> - ); - } else if (status === ClientStatus.DISCONNECTED) { - return ( - <Banner> - <Text id="app.special.status.disconnected" /> - </Banner> - ); - } else if (status === ClientStatus.CONNECTING) { - return ( - <Banner> - <Text id="app.special.status.connecting" /> - </Banner> - ); - } else if (status === ClientStatus.RECONNECTING) { - return ( - <Banner> - <Text id="app.special.status.reconnecting" /> - </Banner> - ); - } - return null; + if (status === ClientStatus.OFFLINE) { + return ( + <Banner> + <Text id="app.special.status.offline" /> + </Banner> + ); + } else if (status === ClientStatus.DISCONNECTED) { + return ( + <Banner> + <Text id="app.special.status.disconnected" /> + </Banner> + ); + } else if (status === ClientStatus.CONNECTING) { + return ( + <Banner> + <Text id="app.special.status.connecting" /> + </Banner> + ); + } else if (status === ClientStatus.RECONNECTING) { + return ( + <Banner> + <Text id="app.special.status.reconnecting" /> + </Banner> + ); + } + return null; } diff --git a/src/components/navigation/left/HomeSidebar.tsx b/src/components/navigation/left/HomeSidebar.tsx index fc883f42aa86667b020c4b2f52c3d3a2cb5aa45f..a0de1380e4d5aec1a3671aab6308102c5434aae9 100644 --- a/src/components/navigation/left/HomeSidebar.tsx +++ b/src/components/navigation/left/HomeSidebar.tsx @@ -1,152 +1,193 @@ +import { + Home, + UserDetail, + Wrench, + Notepad, +} from "@styled-icons/boxicons-solid"; +import { Link, Redirect, useLocation, useParams } from "react-router-dom"; +import { Channels } from "revolt.js/dist/api/objects"; +import { Users as UsersNS } from "revolt.js/dist/api/objects"; + import { Text } from "preact-i18n"; import { useContext, useEffect } from "preact/hooks"; -import { Home, UserDetail, Wrench, Notepad } from "@styled-icons/boxicons-solid"; -import Category from '../../ui/Category'; -import { dispatch } from "../../../redux"; +import ConditionalLink from "../../../lib/ConditionalLink"; import PaintCounter from "../../../lib/PaintCounter"; -import UserHeader from "../../common/user/UserHeader"; -import { Channels } from "revolt.js/dist/api/objects"; +import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; + +import { dispatch } from "../../../redux"; import { connectState } from "../../../redux/connector"; -import ConnectionStatus from '../items/ConnectionStatus'; import { Unreads } from "../../../redux/reducers/unreads"; -import ConditionalLink from "../../../lib/ConditionalLink"; -import { mapChannelWithUnread, useUnreads } from "./common"; -import { Users as UsersNS } from 'revolt.js/dist/api/objects'; -import ButtonItem, { ChannelButton } from '../items/ButtonItem'; -import { AppContext } from "../../../context/revoltjs/RevoltClient"; -import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; -import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; -import { Link, Redirect, useLocation, useParams } from "react-router-dom"; + import { useIntermediate } from "../../../context/intermediate/Intermediate"; -import { useDMs, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { + useDMs, + useForceUpdate, + useUsers, +} from "../../../context/revoltjs/hooks"; +import UserHeader from "../../common/user/UserHeader"; +import Category from "../../ui/Category"; import placeholderSVG from "../items/placeholder.svg"; +import { mapChannelWithUnread, useUnreads } from "./common"; + +import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; +import ButtonItem, { ChannelButton } from "../items/ButtonItem"; +import ConnectionStatus from "../items/ConnectionStatus"; type Props = { - unreads: Unreads; -} + unreads: Unreads; +}; function HomeSidebar(props: Props) { - const { pathname } = useLocation(); - const client = useContext(AppContext); - const { channel } = useParams<{ channel: string }>(); - const { openScreen } = useIntermediate(); - - const ctx = useForceUpdate(); - const channels = useDMs(ctx); - - const obj = channels.find(x => x?._id === channel); - if (channel && !obj) return <Redirect to="/" />; - if (obj) useUnreads({ ...props, channel: obj }); - - useEffect(() => { - if (!channel) return; - - dispatch({ - type: 'LAST_OPENED_SET', - parent: 'home', - child: channel - }); - }, [ channel ]); - - const channelsArr = channels - .filter(x => x.channel_type !== 'SavedMessages') - .map(x => mapChannelWithUnread(x, props.unreads)); - - const users = useUsers( - (channelsArr as (Channels.DirectMessageChannel | Channels.GroupChannel)[]) - .reduce((prev: any, cur) => [ ...prev, ...cur.recipients ], []) - , ctx); - - channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp)); - - return ( - <GenericSidebarBase padding> - <UserHeader user={client.user!} /> - <ConnectionStatus /> - <GenericSidebarList> - {!isTouchscreenDevice && ( - <> - <ConditionalLink active={pathname === "/"} to="/"> - <ButtonItem active={pathname === "/"}> - <Home size={20} /> - <span><Text id="app.navigation.tabs.home" /></span> - </ButtonItem> - </ConditionalLink> - <ConditionalLink active={pathname === "/friends"} to="/friends"> - <ButtonItem - active={pathname === "/friends"} - alert={ - typeof users.find( - user => - user?.relationship === - UsersNS.Relationship.Incoming - ) !== "undefined" ? 'unread' : undefined - } - > - <UserDetail size={20} /> - <span><Text id="app.navigation.tabs.friends" /></span> - </ButtonItem> - </ConditionalLink> - </> - )} - <ConditionalLink active={obj?.channel_type === "SavedMessages"} to="/open/saved"> - <ButtonItem active={obj?.channel_type === "SavedMessages"}> - <Notepad size={20} /> - <span><Text id="app.navigation.tabs.saved" /></span> - </ButtonItem> - </ConditionalLink> - {import.meta.env.DEV && ( - <Link to="/dev"> - <ButtonItem active={pathname === "/dev"}> - <Wrench size={20} /> - <span><Text id="app.navigation.tabs.dev" /></span> - </ButtonItem> - </Link> - )} - <Category - text={<Text id="app.main.categories.conversations" />} - action={() => openScreen({ id: "special_input", type: "create_group" })} /> - {channelsArr.length === 0 && <img src={placeholderSVG} />} - {channelsArr.map(x => { - let user; - if (x.channel_type === 'DirectMessage') { - if (!x.active) return null; - - let recipient = client.channels.getRecipient(x._id); - user = users.find(x => x?._id === recipient); - - if (!user) { - console.warn(`Skipped DM ${x._id} because user was missing.`); - return null; - } - } - - return ( - <ConditionalLink active={x._id === channel} to={`/channel/${x._id}`}> - <ChannelButton - user={user} - channel={x} - alert={x.unread} - alertCount={x.alertCount} - active={x._id === channel} - /> - </ConditionalLink> - ); - })} - <PaintCounter /> - </GenericSidebarList> - </GenericSidebarBase> - ); -}; + const { pathname } = useLocation(); + const client = useContext(AppContext); + const { channel } = useParams<{ channel: string }>(); + const { openScreen } = useIntermediate(); + + const ctx = useForceUpdate(); + const channels = useDMs(ctx); + + const obj = channels.find((x) => x?._id === channel); + if (channel && !obj) return <Redirect to="/" />; + if (obj) useUnreads({ ...props, channel: obj }); + + useEffect(() => { + if (!channel) return; + + dispatch({ + type: "LAST_OPENED_SET", + parent: "home", + child: channel, + }); + }, [channel]); + + const channelsArr = channels + .filter((x) => x.channel_type !== "SavedMessages") + .map((x) => mapChannelWithUnread(x, props.unreads)); + + const users = useUsers( + ( + channelsArr as ( + | Channels.DirectMessageChannel + | Channels.GroupChannel + )[] + ).reduce((prev: any, cur) => [...prev, ...cur.recipients], []), + ctx, + ); + + channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp)); + + return ( + <GenericSidebarBase padding> + <UserHeader user={client.user!} /> + <ConnectionStatus /> + <GenericSidebarList> + {!isTouchscreenDevice && ( + <> + <ConditionalLink active={pathname === "/"} to="/"> + <ButtonItem active={pathname === "/"}> + <Home size={20} /> + <span> + <Text id="app.navigation.tabs.home" /> + </span> + </ButtonItem> + </ConditionalLink> + <ConditionalLink + active={pathname === "/friends"} + to="/friends"> + <ButtonItem + active={pathname === "/friends"} + alert={ + typeof users.find( + (user) => + user?.relationship === + UsersNS.Relationship.Incoming, + ) !== "undefined" + ? "unread" + : undefined + }> + <UserDetail size={20} /> + <span> + <Text id="app.navigation.tabs.friends" /> + </span> + </ButtonItem> + </ConditionalLink> + </> + )} + <ConditionalLink + active={obj?.channel_type === "SavedMessages"} + to="/open/saved"> + <ButtonItem active={obj?.channel_type === "SavedMessages"}> + <Notepad size={20} /> + <span> + <Text id="app.navigation.tabs.saved" /> + </span> + </ButtonItem> + </ConditionalLink> + {import.meta.env.DEV && ( + <Link to="/dev"> + <ButtonItem active={pathname === "/dev"}> + <Wrench size={20} /> + <span> + <Text id="app.navigation.tabs.dev" /> + </span> + </ButtonItem> + </Link> + )} + <Category + text={<Text id="app.main.categories.conversations" />} + action={() => + openScreen({ + id: "special_input", + type: "create_group", + }) + } + /> + {channelsArr.length === 0 && <img src={placeholderSVG} />} + {channelsArr.map((x) => { + let user; + if (x.channel_type === "DirectMessage") { + if (!x.active) return null; + + let recipient = client.channels.getRecipient(x._id); + user = users.find((x) => x?._id === recipient); + + if (!user) { + console.warn( + `Skipped DM ${x._id} because user was missing.`, + ); + return null; + } + } + + return ( + <ConditionalLink + active={x._id === channel} + to={`/channel/${x._id}`}> + <ChannelButton + user={user} + channel={x} + alert={x.unread} + alertCount={x.alertCount} + active={x._id === channel} + /> + </ConditionalLink> + ); + })} + <PaintCounter /> + </GenericSidebarList> + </GenericSidebarBase> + ); +} export default connectState( - HomeSidebar, - state => { - return { - unreads: state.unreads - }; - }, - true + HomeSidebar, + (state) => { + return { + unreads: state.unreads, + }; + }, + true, ); diff --git a/src/components/navigation/left/ServerListSidebar.tsx b/src/components/navigation/left/ServerListSidebar.tsx index 3ba5a986c5beb3de1295577f9cd8d4a74099025b..f75ac8e14f05ff565b091b0f94629a9c1fd74b9f 100644 --- a/src/components/navigation/left/ServerListSidebar.tsx +++ b/src/components/navigation/left/ServerListSidebar.tsx @@ -1,257 +1,298 @@ -import Tooltip from "../../common/Tooltip"; -import IconButton from "../../ui/IconButton"; -import LineDivider from "../../ui/LineDivider"; -import { mapChannelWithUnread } from "./common"; -import styled, { css } from "styled-components"; -import ServerIcon from "../../common/ServerIcon"; -import { Children } from "../../../types/Preact"; -import UserIcon from "../../common/user/UserIcon"; -import PaintCounter from "../../../lib/PaintCounter"; import { Plus } from "@styled-icons/boxicons-regular"; -import { connectState } from "../../../redux/connector"; import { useLocation, useParams } from "react-router-dom"; -import { Unreads } from "../../../redux/reducers/unreads"; -import ConditionalLink from "../../../lib/ConditionalLink"; import { Channel, Servers } from "revolt.js/dist/api/objects"; -import { LastOpened } from "../../../redux/reducers/last_opened"; +import styled, { css } from "styled-components"; + +import { attachContextMenu, openContextMenu } from "preact-context-menu"; + +import ConditionalLink from "../../../lib/ConditionalLink"; +import PaintCounter from "../../../lib/PaintCounter"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; -import { attachContextMenu, openContextMenu } from 'preact-context-menu'; + +import { connectState } from "../../../redux/connector"; +import { LastOpened } from "../../../redux/reducers/last_opened"; +import { Unreads } from "../../../redux/reducers/unreads"; + import { useIntermediate } from "../../../context/intermediate/Intermediate"; -import { useChannels, useForceUpdate, useSelf, useServers } from "../../../context/revoltjs/hooks"; - -function Icon({ children, unread, size }: { children: Children, unread?: 'mention' | 'unread', size: number }) { - return ( - <svg - width={size} - height={size} - aria-hidden="true" - viewBox="0 0 32 32" - > - <use href="#serverIndicator" /> - <foreignObject x="0" y="0" width="32" height="32" mask={ unread ? "url(#server)" : undefined }> - { children } - </foreignObject> - {unread === 'unread' && ( - <circle - cx="27" - cy="5" - r="5" - fill={"white"} - /> - )} - {unread === 'mention' && ( - <circle - cx="27" - cy="5" - r="5" - fill={"red"} - /> - )} - </svg> - ) +import { + useChannels, + useForceUpdate, + useSelf, + useServers, +} from "../../../context/revoltjs/hooks"; + +import ServerIcon from "../../common/ServerIcon"; +import Tooltip from "../../common/Tooltip"; +import UserIcon from "../../common/user/UserIcon"; +import IconButton from "../../ui/IconButton"; +import LineDivider from "../../ui/LineDivider"; +import { mapChannelWithUnread } from "./common"; + +import { Children } from "../../../types/Preact"; + +function Icon({ + children, + unread, + size, +}: { + children: Children; + unread?: "mention" | "unread"; + size: number; +}) { + return ( + <svg width={size} height={size} aria-hidden="true" viewBox="0 0 32 32"> + <use href="#serverIndicator" /> + <foreignObject + x="0" + y="0" + width="32" + height="32" + mask={unread ? "url(#server)" : undefined}> + {children} + </foreignObject> + {unread === "unread" && ( + <circle cx="27" cy="5" r="5" fill={"white"} /> + )} + {unread === "mention" && ( + <circle cx="27" cy="5" r="5" fill={"red"} /> + )} + </svg> + ); } const ServersBase = styled.div` - width: 56px; - height: 100%; - display: flex; - flex-direction: column; - - ${ isTouchscreenDevice && css` - padding-bottom: 50px; - ` } + width: 56px; + height: 100%; + display: flex; + flex-direction: column; + + ${isTouchscreenDevice && + css` + padding-bottom: 50px; + `} `; const ServerList = styled.div` - flex-grow: 1; - display: flex; - overflow-y: scroll; - padding-bottom: 48px; - flex-direction: column; - // border-right: 2px solid var(--accent); - - scrollbar-width: none; - - > :first-child > svg { - margin: 6px 0 6px 4px; - } - - &::-webkit-scrollbar { - width: 0px; - } + flex-grow: 1; + display: flex; + overflow-y: scroll; + padding-bottom: 48px; + flex-direction: column; + // border-right: 2px solid var(--accent); + + scrollbar-width: none; + + > :first-child > svg { + margin: 6px 0 6px 4px; + } + + &::-webkit-scrollbar { + width: 0px; + } `; -const ServerEntry = styled.div<{ active: boolean, home?: boolean }>` - height: 58px; - display: flex; - align-items: center; - justify-content: flex-end; - - > * { - // outline: 1px solid red; - } - - > div { - width: 46px; - height: 46px; - display: grid; - place-items: center; - - border-start-start-radius: 50%; - border-end-start-radius: 50%; - - &:active { - transform: translateY(1px); - } - - ${ props => props.active && css` - background: var(--sidebar-active); - &:active { - transform: none; - } - ` } - } - - span { - width: 6px; - height: 46px; - - ${ props => props.active && css` - background-color: var(--sidebar-active); - - &::before, &::after { - // outline: 1px solid blue; - } - - &::before, &::after { - content: ""; - display: block; - position: relative; - - width: 31px; - height: 72px; - margin-top: -72px; - margin-left: -25px; - z-index: -1; - - background-color: var(--background); - border-bottom-right-radius: 32px; - - box-shadow: 0 32px 0 0 var(--sidebar-active); - } - - &::after { - transform: scaleY(-1) translateY(-118px); - } - ` } - } - - ${ props => (!props.active || props.home) && css` - cursor: pointer; - ` } +const ServerEntry = styled.div<{ active: boolean; home?: boolean }>` + height: 58px; + display: flex; + align-items: center; + justify-content: flex-end; + + > * { + // outline: 1px solid red; + } + + > div { + width: 46px; + height: 46px; + display: grid; + place-items: center; + + border-start-start-radius: 50%; + border-end-start-radius: 50%; + + &:active { + transform: translateY(1px); + } + + ${(props) => + props.active && + css` + background: var(--sidebar-active); + &:active { + transform: none; + } + `} + } + + span { + width: 6px; + height: 46px; + + ${(props) => + props.active && + css` + background-color: var(--sidebar-active); + + &::before, + &::after { + // outline: 1px solid blue; + } + + &::before, + &::after { + content: ""; + display: block; + position: relative; + + width: 31px; + height: 72px; + margin-top: -72px; + margin-left: -25px; + z-index: -1; + + background-color: var(--background); + border-bottom-right-radius: 32px; + + box-shadow: 0 32px 0 0 var(--sidebar-active); + } + + &::after { + transform: scaleY(-1) translateY(-118px); + } + `} + } + + ${(props) => + (!props.active || props.home) && + css` + cursor: pointer; + `} `; interface Props { - unreads: Unreads; - lastOpened: LastOpened; + unreads: Unreads; + lastOpened: LastOpened; } export function ServerListSidebar({ unreads, lastOpened }: Props) { - const ctx = useForceUpdate(); - const self = useSelf(ctx); - const activeServers = useServers(undefined, ctx) as Servers.Server[]; - const channels = (useChannels(undefined, ctx) as Channel[]) - .map(x => mapChannelWithUnread(x, unreads)); - - const unreadChannels = channels.filter(x => x.unread) - .map(x => x._id); - - const servers = activeServers.map(server => { - let alertCount = 0; - for (let id of server.channels) { - let channel = channels.find(x => x._id === id); - if (channel?.alertCount) { - alertCount += channel.alertCount; - } - } - - return { - ...server, - unread: (typeof server.channels.find(x => unreadChannels.includes(x)) !== 'undefined' ? - ( alertCount > 0 ? 'mention' : 'unread' ) : undefined) as 'mention' | 'unread' | undefined, - alertCount - } - }); - - const path = useLocation().pathname; - const { server: server_id } = useParams<{ server?: string }>(); - const server = servers.find(x => x!._id == server_id); - - const { openScreen } = useIntermediate(); - - let homeUnread: 'mention' | 'unread' | undefined; - let alertCount = 0; - for (let x of channels) { - if (((x.channel_type === 'DirectMessage' && x.active) || x.channel_type === 'Group') && x.unread) { - homeUnread = 'unread'; - alertCount += x.alertCount ?? 0; - } - } - - if (alertCount > 0) homeUnread = 'mention'; - const homeActive = typeof server === 'undefined' && !path.startsWith('/invite'); - - return ( - <ServersBase> - <ServerList> - <ConditionalLink active={homeActive} to={lastOpened.home ? `/channel/${lastOpened.home}` : '/'}> - <ServerEntry home active={homeActive}> - <div onContextMenu={attachContextMenu('Status')} - onClick={() => homeActive && openContextMenu("Status")}> - <Icon size={42} unread={homeUnread}> - <UserIcon target={self} size={32} status /> - </Icon> - </div> - <span /> - </ServerEntry> - </ConditionalLink> - <LineDivider /> - { - servers.map(entry => { - const active = entry!._id === server?._id; - const id = lastOpened[entry!._id]; - - return ( - <ConditionalLink active={active} to={`/server/${entry!._id}` + (id ? `/channel/${id}` : '')}> - <ServerEntry - active={active} - onContextMenu={attachContextMenu('Menu', { server: entry!._id })}> - <Tooltip content={entry.name} placement="right"> - <Icon size={42} unread={entry.unread}> - <ServerIcon size={32} target={entry} /> - </Icon> - </Tooltip> - <span /> - </ServerEntry> - </ConditionalLink> - ) - }) - } - <IconButton onClick={() => openScreen({ id: 'special_input', type: 'create_server' })}> - <Plus size={36} /> - </IconButton> - <PaintCounter small /> - </ServerList> - </ServersBase> - ) + const ctx = useForceUpdate(); + const self = useSelf(ctx); + const activeServers = useServers(undefined, ctx) as Servers.Server[]; + const channels = (useChannels(undefined, ctx) as Channel[]).map((x) => + mapChannelWithUnread(x, unreads), + ); + + const unreadChannels = channels.filter((x) => x.unread).map((x) => x._id); + + const servers = activeServers.map((server) => { + let alertCount = 0; + for (let id of server.channels) { + let channel = channels.find((x) => x._id === id); + if (channel?.alertCount) { + alertCount += channel.alertCount; + } + } + + return { + ...server, + unread: (typeof server.channels.find((x) => + unreadChannels.includes(x), + ) !== "undefined" + ? alertCount > 0 + ? "mention" + : "unread" + : undefined) as "mention" | "unread" | undefined, + alertCount, + }; + }); + + const path = useLocation().pathname; + const { server: server_id } = useParams<{ server?: string }>(); + const server = servers.find((x) => x!._id == server_id); + + const { openScreen } = useIntermediate(); + + let homeUnread: "mention" | "unread" | undefined; + let alertCount = 0; + for (let x of channels) { + if ( + ((x.channel_type === "DirectMessage" && x.active) || + x.channel_type === "Group") && + x.unread + ) { + homeUnread = "unread"; + alertCount += x.alertCount ?? 0; + } + } + + if (alertCount > 0) homeUnread = "mention"; + const homeActive = + typeof server === "undefined" && !path.startsWith("/invite"); + + return ( + <ServersBase> + <ServerList> + <ConditionalLink + active={homeActive} + to={lastOpened.home ? `/channel/${lastOpened.home}` : "/"}> + <ServerEntry home active={homeActive}> + <div + onContextMenu={attachContextMenu("Status")} + onClick={() => + homeActive && openContextMenu("Status") + }> + <Icon size={42} unread={homeUnread}> + <UserIcon target={self} size={32} status /> + </Icon> + </div> + <span /> + </ServerEntry> + </ConditionalLink> + <LineDivider /> + {servers.map((entry) => { + const active = entry!._id === server?._id; + const id = lastOpened[entry!._id]; + + return ( + <ConditionalLink + active={active} + to={ + `/server/${entry!._id}` + + (id ? `/channel/${id}` : "") + }> + <ServerEntry + active={active} + onContextMenu={attachContextMenu("Menu", { + server: entry!._id, + })}> + <Tooltip content={entry.name} placement="right"> + <Icon size={42} unread={entry.unread}> + <ServerIcon size={32} target={entry} /> + </Icon> + </Tooltip> + <span /> + </ServerEntry> + </ConditionalLink> + ); + })} + <IconButton + onClick={() => + openScreen({ + id: "special_input", + type: "create_server", + }) + }> + <Plus size={36} /> + </IconButton> + <PaintCounter small /> + </ServerList> + </ServersBase> + ); } -export default connectState( - ServerListSidebar, - state => { - return { - unreads: state.unreads, - lastOpened: state.lastOpened - }; - } -); +export default connectState(ServerListSidebar, (state) => { + return { + unreads: state.unreads, + lastOpened: state.lastOpened, + }; +}); diff --git a/src/components/navigation/left/ServerSidebar.tsx b/src/components/navigation/left/ServerSidebar.tsx index 280dd8295c9a89677a58fde2c5740367d157d35f..38bc1645c96b204feb51e3912f35438b8a24442f 100644 --- a/src/components/navigation/left/ServerSidebar.tsx +++ b/src/components/navigation/left/ServerSidebar.tsx @@ -1,134 +1,150 @@ import { Redirect, useParams } from "react-router"; -import { ChannelButton } from "../items/ButtonItem"; import { Channels } from "revolt.js/dist/api/objects"; -import { Unreads } from "../../../redux/reducers/unreads"; -import { useChannels, useForceUpdate, useServer } from "../../../context/revoltjs/hooks"; -import { mapChannelWithUnread, useUnreads } from "./common"; -import ConnectionStatus from '../items/ConnectionStatus'; -import { connectState } from "../../../redux/connector"; -import PaintCounter from "../../../lib/PaintCounter"; import styled from "styled-components"; -import { attachContextMenu } from 'preact-context-menu'; -import ServerHeader from "../../common/ServerHeader"; + +import { attachContextMenu } from "preact-context-menu"; import { useEffect } from "preact/hooks"; -import Category from "../../ui/Category"; -import { dispatch } from "../../../redux"; + import ConditionalLink from "../../../lib/ConditionalLink"; +import PaintCounter from "../../../lib/PaintCounter"; + +import { dispatch } from "../../../redux"; +import { connectState } from "../../../redux/connector"; +import { Unreads } from "../../../redux/reducers/unreads"; + +import { + useChannels, + useForceUpdate, + useServer, +} from "../../../context/revoltjs/hooks"; + import CollapsibleSection from "../../common/CollapsibleSection"; +import ServerHeader from "../../common/ServerHeader"; +import Category from "../../ui/Category"; +import { mapChannelWithUnread, useUnreads } from "./common"; + +import { ChannelButton } from "../items/ButtonItem"; +import ConnectionStatus from "../items/ConnectionStatus"; interface Props { - unreads: Unreads; + unreads: Unreads; } const ServerBase = styled.div` - height: 100%; - width: 240px; - display: flex; - flex-shrink: 0; - flex-direction: column; - background: var(--secondary-background); - - border-start-start-radius: 8px; - border-end-start-radius: 8px; - overflow: hidden; + height: 100%; + width: 240px; + display: flex; + flex-shrink: 0; + flex-direction: column; + background: var(--secondary-background); + + border-start-start-radius: 8px; + border-end-start-radius: 8px; + overflow: hidden; `; const ServerList = styled.div` - padding: 6px; - flex-grow: 1; - overflow-y: scroll; + padding: 6px; + flex-grow: 1; + overflow-y: scroll; - > svg { - width: 100%; - } + > svg { + width: 100%; + } `; function ServerSidebar(props: Props) { - const { server: server_id, channel: channel_id } = useParams<{ server?: string, channel?: string }>(); - const ctx = useForceUpdate(); - - const server = useServer(server_id, ctx); - if (!server) return <Redirect to="/" />; - - const channels = (useChannels(server.channels, ctx) - .filter(entry => typeof entry !== 'undefined') as Readonly<Channels.TextChannel | Channels.VoiceChannel>[]) - .map(x => mapChannelWithUnread(x, props.unreads)); - - const channel = channels.find(x => x?._id === channel_id); - if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />; - if (channel) useUnreads({ ...props, channel }, ctx); - - useEffect(() => { - if (!channel_id) return; - - dispatch({ - type: 'LAST_OPENED_SET', - parent: server_id!, - child: channel_id! - }); - }, [ channel_id ]); - - let uncategorised = new Set(server.channels); - let elements = []; - - function addChannel(id: string) { - const entry = channels.find(x => x._id === id); - if (!entry) return; - - const active = channel?._id === entry._id; - - return ( - <ConditionalLink key={entry._id} active={active} to={`/server/${server!._id}/channel/${entry._id}`}> - <ChannelButton - channel={entry} - active={active} - alert={entry.unread} - compact - /> - </ConditionalLink> - ); - } - - if (server.categories) { - for (let category of server.categories) { - let channels = []; - for (let id of category.channels) { - uncategorised.delete(id); - channels.push(addChannel(id)); - } - - elements.push( - <CollapsibleSection - id={`category_${category.id}`} - defaultValue - summary={<Category text={category.title} />}> - { channels } - </CollapsibleSection> - ); - } - } - - for (let id of Array.from(uncategorised).reverse()) { - elements.unshift(addChannel(id)); - } - - return ( - <ServerBase> - <ServerHeader server={server} ctx={ctx} /> - <ConnectionStatus /> - <ServerList onContextMenu={attachContextMenu('Menu', { server_list: server._id })}> - { elements } - </ServerList> - <PaintCounter small /> - </ServerBase> - ) -}; - -export default connectState( - ServerSidebar, - state => { - return { - unreads: state.unreads - }; - } -); + const { server: server_id, channel: channel_id } = + useParams<{ server?: string; channel?: string }>(); + const ctx = useForceUpdate(); + + const server = useServer(server_id, ctx); + if (!server) return <Redirect to="/" />; + + const channels = ( + useChannels(server.channels, ctx).filter( + (entry) => typeof entry !== "undefined", + ) as Readonly<Channels.TextChannel | Channels.VoiceChannel>[] + ).map((x) => mapChannelWithUnread(x, props.unreads)); + + const channel = channels.find((x) => x?._id === channel_id); + if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />; + if (channel) useUnreads({ ...props, channel }, ctx); + + useEffect(() => { + if (!channel_id) return; + + dispatch({ + type: "LAST_OPENED_SET", + parent: server_id!, + child: channel_id!, + }); + }, [channel_id]); + + let uncategorised = new Set(server.channels); + let elements = []; + + function addChannel(id: string) { + const entry = channels.find((x) => x._id === id); + if (!entry) return; + + const active = channel?._id === entry._id; + + return ( + <ConditionalLink + key={entry._id} + active={active} + to={`/server/${server!._id}/channel/${entry._id}`}> + <ChannelButton + channel={entry} + active={active} + alert={entry.unread} + compact + /> + </ConditionalLink> + ); + } + + if (server.categories) { + for (let category of server.categories) { + let channels = []; + for (let id of category.channels) { + uncategorised.delete(id); + channels.push(addChannel(id)); + } + + elements.push( + <CollapsibleSection + id={`category_${category.id}`} + defaultValue + summary={<Category text={category.title} />}> + {channels} + </CollapsibleSection>, + ); + } + } + + for (let id of Array.from(uncategorised).reverse()) { + elements.unshift(addChannel(id)); + } + + return ( + <ServerBase> + <ServerHeader server={server} ctx={ctx} /> + <ConnectionStatus /> + <ServerList + onContextMenu={attachContextMenu("Menu", { + server_list: server._id, + })}> + {elements} + </ServerList> + <PaintCounter small /> + </ServerBase> + ); +} + +export default connectState(ServerSidebar, (state) => { + return { + unreads: state.unreads, + }; +}); diff --git a/src/components/navigation/left/common.ts b/src/components/navigation/left/common.ts index f7ca164fbc47e1f2489952098294ec05366d894c..89168031ff38f0c853913e73b9e7f23877492bde 100644 --- a/src/components/navigation/left/common.ts +++ b/src/components/navigation/left/common.ts @@ -1,76 +1,103 @@ import { Channel } from "revolt.js"; -import { dispatch } from "../../../redux"; + import { useLayoutEffect } from "preact/hooks"; + +import { dispatch } from "../../../redux"; import { Unreads } from "../../../redux/reducers/unreads"; + import { HookContext, useForceUpdate } from "../../../context/revoltjs/hooks"; type UnreadProps = { - channel: Channel; - unreads: Unreads; -} + channel: Channel; + unreads: Unreads; +}; + +export function useUnreads( + { channel, unreads }: UnreadProps, + context?: HookContext, +) { + const ctx = useForceUpdate(context); -export function useUnreads({ channel, unreads }: UnreadProps, context?: HookContext) { - const ctx = useForceUpdate(context); + useLayoutEffect(() => { + function checkUnread(target?: Channel) { + if (!target) return; + if (target._id !== channel._id) return; + if ( + target.channel_type === "SavedMessages" || + target.channel_type === "VoiceChannel" + ) + return; - useLayoutEffect(() => { - function checkUnread(target?: Channel) { - if (!target) return; - if (target._id !== channel._id) return; - if (target.channel_type === "SavedMessages" || - target.channel_type === "VoiceChannel") return; + const unread = unreads[channel._id]?.last_id; + if (target.last_message) { + const message = + typeof target.last_message === "string" + ? target.last_message + : target.last_message._id; + if (!unread || (unread && message.localeCompare(unread) > 0)) { + dispatch({ + type: "UNREADS_MARK_READ", + channel: channel._id, + message, + }); - const unread = unreads[channel._id]?.last_id; - if (target.last_message) { - const message = typeof target.last_message === 'string' ? target.last_message : target.last_message._id; - if (!unread || (unread && message.localeCompare(unread) > 0)) { - dispatch({ - type: "UNREADS_MARK_READ", - channel: channel._id, - message - }); - - ctx.client.req('PUT', `/channels/${channel._id}/ack/${message}` as '/channels/id/ack/id'); - } - } - } + ctx.client.req( + "PUT", + `/channels/${channel._id}/ack/${message}` as "/channels/id/ack/id", + ); + } + } + } - checkUnread(channel); + checkUnread(channel); - ctx.client.channels.addListener("mutation", checkUnread); - return () => ctx.client.channels.removeListener("mutation", checkUnread); - }, [channel, unreads]); + ctx.client.channels.addListener("mutation", checkUnread); + return () => + ctx.client.channels.removeListener("mutation", checkUnread); + }, [channel, unreads]); } export function mapChannelWithUnread(channel: Channel, unreads: Unreads) { - let last_message_id; - if (channel.channel_type === 'DirectMessage' || channel.channel_type === 'Group') { - last_message_id = channel.last_message?._id; - } else if (channel.channel_type === 'TextChannel') { - last_message_id = channel.last_message; - } else { - return { ...channel, unread: undefined, alertCount: undefined, timestamp: channel._id }; - } + let last_message_id; + if ( + channel.channel_type === "DirectMessage" || + channel.channel_type === "Group" + ) { + last_message_id = channel.last_message?._id; + } else if (channel.channel_type === "TextChannel") { + last_message_id = channel.last_message; + } else { + return { + ...channel, + unread: undefined, + alertCount: undefined, + timestamp: channel._id, + }; + } - let unread: 'mention' | 'unread' | undefined; - let alertCount: undefined | number; - if (last_message_id && unreads) { - const u = unreads[channel._id]; - if (u) { - if (u.mentions && u.mentions.length > 0) { - alertCount = u.mentions.length; - unread = 'mention'; - } else if (u.last_id && last_message_id.localeCompare(u.last_id) > 0) { - unread = 'unread'; - } - } else { - unread = 'unread'; - } - } + let unread: "mention" | "unread" | undefined; + let alertCount: undefined | number; + if (last_message_id && unreads) { + const u = unreads[channel._id]; + if (u) { + if (u.mentions && u.mentions.length > 0) { + alertCount = u.mentions.length; + unread = "mention"; + } else if ( + u.last_id && + last_message_id.localeCompare(u.last_id) > 0 + ) { + unread = "unread"; + } + } else { + unread = "unread"; + } + } - return { - ...channel, - timestamp: last_message_id ?? channel._id, - unread, - alertCount - }; + return { + ...channel, + timestamp: last_message_id ?? channel._id, + unread, + alertCount, + }; } diff --git a/src/components/navigation/right/ChannelDebugInfo.tsx b/src/components/navigation/right/ChannelDebugInfo.tsx index 8584288176818f11a8eb58ea2634eaceb6f4650b..392f547ee13efec324ff5b3f7e37c4caee74d1db 100644 --- a/src/components/navigation/right/ChannelDebugInfo.tsx +++ b/src/components/navigation/right/ChannelDebugInfo.tsx @@ -1,37 +1,40 @@ import { useRenderState } from "../../../lib/renderer/Singleton"; interface Props { - id: string; + id: string; } export function ChannelDebugInfo({ id }: Props) { - if (process.env.NODE_ENV !== "development") return null; - let view = useRenderState(id); - if (!view) return null; + if (process.env.NODE_ENV !== "development") return null; + let view = useRenderState(id); + if (!view) return null; - return ( - <span style={{ display: "block", padding: "12px 10px 0 10px" }}> - <span - style={{ - display: "block", - fontSize: "12px", - textTransform: "uppercase", - fontWeight: "600" - }} - > - Channel Info - </span> - <p style={{ fontSize: "10px", userSelect: "text" }}> - State: <b>{ view.type }</b> <br /> - { view.type === 'RENDER' && view.messages.length > 0 && - <> - Start: <b>{view.messages[0]._id}</b> <br /> - End: <b>{view.messages[view.messages.length - 1]._id}</b> <br /> - At Top: <b>{view.atTop ? "Yes" : "No"}</b> <br /> - At Bottom: <b>{view.atBottom ? "Yes" : "No"}</b> - </> - } - </p> - </span> - ); + return ( + <span style={{ display: "block", padding: "12px 10px 0 10px" }}> + <span + style={{ + display: "block", + fontSize: "12px", + textTransform: "uppercase", + fontWeight: "600", + }}> + Channel Info + </span> + <p style={{ fontSize: "10px", userSelect: "text" }}> + State: <b>{view.type}</b> <br /> + {view.type === "RENDER" && view.messages.length > 0 && ( + <> + Start: <b>{view.messages[0]._id}</b> <br /> + End:{" "} + <b> + {view.messages[view.messages.length - 1]._id} + </b>{" "} + <br /> + At Top: <b>{view.atTop ? "Yes" : "No"}</b> <br /> + At Bottom: <b>{view.atBottom ? "Yes" : "No"}</b> + </> + )} + </p> + </span> + ); } diff --git a/src/components/navigation/right/MemberSidebar.tsx b/src/components/navigation/right/MemberSidebar.tsx index 40be8041dd0fcae9d11a6313c9a0a680bdadff88..a24fc9379ec6acfb6af52a0252eb2a5ce6a2b1d7 100644 --- a/src/components/navigation/right/MemberSidebar.tsx +++ b/src/components/navigation/right/MemberSidebar.tsx @@ -1,45 +1,62 @@ +import { useParams } from "react-router"; +import { User } from "revolt.js"; +import { Channels, Servers, Users } from "revolt.js/dist/api/objects"; +import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; + import { Text } from "preact-i18n"; import { useContext, useEffect, useState } from "preact/hooks"; -import { User } from "revolt.js"; -import Category from "../../ui/Category"; -import { useParams } from "react-router"; -import { UserButton } from "../items/ButtonItem"; -import { ChannelDebugInfo } from "./ChannelDebugInfo"; -import { Channels, Servers, Users } from "revolt.js/dist/api/objects"; -import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; -import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; -import { AppContext, ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; -import { HookContext, useChannel, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; +import { + AppContext, + ClientStatus, + StatusContext, +} from "../../../context/revoltjs/RevoltClient"; +import { + HookContext, + useChannel, + useForceUpdate, + useUsers, +} from "../../../context/revoltjs/hooks"; -import placeholderSVG from "../items/placeholder.svg"; +import Category from "../../ui/Category"; import Preloader from "../../ui/Preloader"; +import placeholderSVG from "../items/placeholder.svg"; + +import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; +import { UserButton } from "../items/ButtonItem"; +import { ChannelDebugInfo } from "./ChannelDebugInfo"; interface Props { - ctx: HookContext + ctx: HookContext; } export default function MemberSidebar(props: { channel?: Channels.Channel }) { - const ctx = useForceUpdate(); - const { channel: cid } = useParams<{ channel: string }>(); - const channel = props.channel ?? useChannel(cid, ctx); - - switch (channel?.channel_type) { - case 'Group': return <GroupMemberSidebar channel={channel} ctx={ctx} />; - case 'TextChannel': return <ServerMemberSidebar channel={channel} ctx={ctx} />; - default: return null; - } + const ctx = useForceUpdate(); + const { channel: cid } = useParams<{ channel: string }>(); + const channel = props.channel ?? useChannel(cid, ctx); + + switch (channel?.channel_type) { + case "Group": + return <GroupMemberSidebar channel={channel} ctx={ctx} />; + case "TextChannel": + return <ServerMemberSidebar channel={channel} ctx={ctx} />; + default: + return null; + } } -export function GroupMemberSidebar({ channel, ctx }: Props & { channel: Channels.GroupChannel }) { - const { openScreen } = useIntermediate(); - const users = useUsers(undefined, ctx); - let members = channel.recipients - .map(x => users.find(y => y?._id === x)) - .filter(x => typeof x !== "undefined") as User[]; +export function GroupMemberSidebar({ + channel, + ctx, +}: Props & { channel: Channels.GroupChannel }) { + const { openScreen } = useIntermediate(); + const users = useUsers(undefined, ctx); + let members = channel.recipients + .map((x) => users.find((y) => y?._id === x)) + .filter((x) => typeof x !== "undefined") as User[]; - /*const voice = useContext(VoiceContext); + /*const voice = useContext(VoiceContext); const voiceActive = voice.roomId === channel._id; let voiceParticipants: User[] = []; @@ -54,24 +71,32 @@ export function GroupMemberSidebar({ channel, ctx }: Props & { channel: Channels voiceParticipants.sort((a, b) => a.username.localeCompare(b.username)); }*/ - members.sort((a, b) => { - // ! FIXME: should probably rewrite all this code - let l = +((a.online && a.status?.presence !== Users.Presence.Invisible) ?? false) | 0; - let r = +((b.online && b.status?.presence !== Users.Presence.Invisible) ?? false) | 0; + members.sort((a, b) => { + // ! FIXME: should probably rewrite all this code + let l = + +( + (a.online && a.status?.presence !== Users.Presence.Invisible) ?? + false + ) | 0; + let r = + +( + (b.online && b.status?.presence !== Users.Presence.Invisible) ?? + false + ) | 0; - let n = r - l; - if (n !== 0) { - return n; - } + let n = r - l; + if (n !== 0) { + return n; + } - return a.username.localeCompare(b.username); - }); + return a.username.localeCompare(b.username); + }); - return ( - <GenericSidebarBase> - <GenericSidebarList> - <ChannelDebugInfo id={channel._id} /> - {/*voiceActive && voiceParticipants.length !== 0 && ( + return ( + <GenericSidebarBase> + <GenericSidebarList> + <ChannelDebugInfo id={channel._id} /> + {/*voiceActive && voiceParticipants.length !== 0 && ( <Fragment> <Category type="members" @@ -96,104 +121,146 @@ export function GroupMemberSidebar({ channel, ctx }: Props & { channel: Channels )} </Fragment> )*/} - {!(members.length === 0 /*&& voiceActive*/) && ( - <Category - variant="uniform" - text={ - <span> - <Text id="app.main.categories.members" /> —{" "} - {channel.recipients.length} - </span> - } - /> - )} - {members.length === 0 && /*!voiceActive &&*/ <img src={placeholderSVG} />} - {members.map( - user => - user && ( - <UserButton - key={user._id} - user={user} - context={channel} - onClick={() => openScreen({ id: 'profile', user_id: user._id })} /> - ) - )} - </GenericSidebarList> - </GenericSidebarBase> - ); + {!((members.length === 0) /*&& voiceActive*/) && ( + <Category + variant="uniform" + text={ + <span> + <Text id="app.main.categories.members" /> —{" "} + {channel.recipients.length} + </span> + } + /> + )} + {members.length === 0 && ( + /*!voiceActive &&*/ <img src={placeholderSVG} /> + )} + {members.map( + (user) => + user && ( + <UserButton + key={user._id} + user={user} + context={channel} + onClick={() => + openScreen({ + id: "profile", + user_id: user._id, + }) + } + /> + ), + )} + </GenericSidebarList> + </GenericSidebarBase> + ); } -export function ServerMemberSidebar({ channel, ctx }: Props & { channel: Channels.TextChannel }) { - const [members, setMembers] = useState<Servers.Member[] | undefined>(undefined); - const users = useUsers(members?.map(x => x._id.user) ?? []).filter(x => typeof x !== 'undefined', ctx) as Users.User[]; - const { openScreen } = useIntermediate(); - const status = useContext(StatusContext); - const client = useContext(AppContext); - - useEffect(() => { - if (status === ClientStatus.ONLINE && typeof members === 'undefined') { - client.servers.members.fetchMembers(channel.server) - .then(members => setMembers(members)) - } - }, [ status ]); - - // ! FIXME: temporary code - useEffect(() => { - function onPacket(packet: ClientboundNotification) { - if (!members) return; - if (packet.type === 'ServerMemberJoin') { - if (packet.id !== channel.server) return; - setMembers([ ...members, { _id: { server: packet.id, user: packet.user } } ]); - } else if (packet.type === 'ServerMemberLeave') { - if (packet.id !== channel.server) return; - setMembers(members.filter(x => !(x._id.user === packet.user && x._id.server === packet.id))); - } - } - - client.addListener('packet', onPacket); - return () => client.removeListener('packet', onPacket); - }, [ members ]); - - // copy paste from above - users.sort((a, b) => { - // ! FIXME: should probably rewrite all this code - let l = +((a.online && a.status?.presence !== Users.Presence.Invisible) ?? false) | 0; - let r = +((b.online && b.status?.presence !== Users.Presence.Invisible) ?? false) | 0; - - let n = r - l; - if (n !== 0) { - return n; - } - - return a.username.localeCompare(b.username); - }); - - return ( - <GenericSidebarBase> - <GenericSidebarList> - <ChannelDebugInfo id={channel._id} /> - <Category - variant="uniform" - text={ - <span> - <Text id="app.main.categories.members" /> —{" "} - {users.length} - </span> - } - /> - {!members && <Preloader type="ring" />} - {members && users.length === 0 && <img src={placeholderSVG} />} - {users.map( - user => - user && ( - <UserButton - key={user._id} - user={user} - context={channel} - onClick={() => openScreen({ id: 'profile', user_id: user._id })} /> - ) - )} - </GenericSidebarList> - </GenericSidebarBase> - ); +export function ServerMemberSidebar({ + channel, + ctx, +}: Props & { channel: Channels.TextChannel }) { + const [members, setMembers] = useState<Servers.Member[] | undefined>( + undefined, + ); + const users = useUsers(members?.map((x) => x._id.user) ?? []).filter( + (x) => typeof x !== "undefined", + ctx, + ) as Users.User[]; + const { openScreen } = useIntermediate(); + const status = useContext(StatusContext); + const client = useContext(AppContext); + + useEffect(() => { + if (status === ClientStatus.ONLINE && typeof members === "undefined") { + client.servers.members + .fetchMembers(channel.server) + .then((members) => setMembers(members)); + } + }, [status]); + + // ! FIXME: temporary code + useEffect(() => { + function onPacket(packet: ClientboundNotification) { + if (!members) return; + if (packet.type === "ServerMemberJoin") { + if (packet.id !== channel.server) return; + setMembers([ + ...members, + { _id: { server: packet.id, user: packet.user } }, + ]); + } else if (packet.type === "ServerMemberLeave") { + if (packet.id !== channel.server) return; + setMembers( + members.filter( + (x) => + !( + x._id.user === packet.user && + x._id.server === packet.id + ), + ), + ); + } + } + + client.addListener("packet", onPacket); + return () => client.removeListener("packet", onPacket); + }, [members]); + + // copy paste from above + users.sort((a, b) => { + // ! FIXME: should probably rewrite all this code + let l = + +( + (a.online && a.status?.presence !== Users.Presence.Invisible) ?? + false + ) | 0; + let r = + +( + (b.online && b.status?.presence !== Users.Presence.Invisible) ?? + false + ) | 0; + + let n = r - l; + if (n !== 0) { + return n; + } + + return a.username.localeCompare(b.username); + }); + + return ( + <GenericSidebarBase> + <GenericSidebarList> + <ChannelDebugInfo id={channel._id} /> + <Category + variant="uniform" + text={ + <span> + <Text id="app.main.categories.members" /> —{" "} + {users.length} + </span> + } + /> + {!members && <Preloader type="ring" />} + {members && users.length === 0 && <img src={placeholderSVG} />} + {users.map( + (user) => + user && ( + <UserButton + key={user._id} + user={user} + context={channel} + onClick={() => + openScreen({ + id: "profile", + user_id: user._id, + }) + } + /> + ), + )} + </GenericSidebarList> + </GenericSidebarBase> + ); } diff --git a/src/components/ui/Banner.tsx b/src/components/ui/Banner.tsx index 4b96d20529087d7b5645239279575ad6f27fd83f..1ff99f8344f3127afa1c83bb217f7b3d4ba37565 100644 --- a/src/components/ui/Banner.tsx +++ b/src/components/ui/Banner.tsx @@ -1,10 +1,10 @@ import styled from "styled-components"; export default styled.div` - padding: 8px; - font-size: 14px; - text-align: center; + padding: 8px; + font-size: 14px; + text-align: center; - color: var(--accent); - background: var(--primary-background); + color: var(--accent); + background: var(--primary-background); `; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 870e7ed22a463a87be437f63f97039f21925c89d..7604da39a277e754ca885b2f93edb89075dfab2a 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -1,71 +1,71 @@ import styled, { css } from "styled-components"; interface Props { - readonly contrast?: boolean; - readonly error?: boolean; + readonly contrast?: boolean; + readonly error?: boolean; } export default styled.button<Props>` - z-index: 1; - padding: 8px; - font-size: 16px; - text-align: center; - font-family: inherit; + z-index: 1; + padding: 8px; + font-size: 16px; + text-align: center; + font-family: inherit; - transition: 0.2s ease opacity; - transition: 0.2s ease background-color; + transition: 0.2s ease opacity; + transition: 0.2s ease background-color; - background: var(--primary-background); - color: var(--foreground); + background: var(--primary-background); + color: var(--foreground); - border-radius: 6px; - cursor: pointer; - border: none; + border-radius: 6px; + cursor: pointer; + border: none; - &:hover { - background: var(--secondary-header); - } + &:hover { + background: var(--secondary-header); + } - &:disabled { - background: var(--primary-background); - } + &:disabled { + background: var(--primary-background); + } - &:active { - background: var(--secondary-background); - } + &:active { + background: var(--secondary-background); + } - ${(props) => - props.contrast && - css` - padding: 4px 8px; - background: var(--secondary-header); + ${(props) => + props.contrast && + css` + padding: 4px 8px; + background: var(--secondary-header); - &:hover { - background: var(--primary-header); - } + &:hover { + background: var(--primary-header); + } - &:disabled { - background: var(--secondary-header); - } + &:disabled { + background: var(--secondary-header); + } - &:active { - background: var(--secondary-background); - } - `} + &:active { + background: var(--secondary-background); + } + `} - ${(props) => - props.error && - css` - color: white; - background: var(--error); + ${(props) => + props.error && + css` + color: white; + background: var(--error); - &:hover { - filter: brightness(1.2); - background: var(--error); - } + &:hover { + filter: brightness(1.2); + background: var(--error); + } - &:disabled { - background: var(--error); - } - `} + &:disabled { + background: var(--error); + } + `} `; diff --git a/src/components/ui/Category.tsx b/src/components/ui/Category.tsx index 3f76d2846d585b5b5beaabdfe5b3e5d8e793b020..80d117cfa1fcb45a4514faaafccd38fcda958af8 100644 --- a/src/components/ui/Category.tsx +++ b/src/components/ui/Category.tsx @@ -1,51 +1,55 @@ +import { Plus } from "@styled-icons/boxicons-regular"; import styled, { css } from "styled-components"; + import { Children } from "../../types/Preact"; -import { Plus } from "@styled-icons/boxicons-regular"; -const CategoryBase = styled.div<Pick<Props, 'variant'>>` - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - - margin-top: 4px; - padding: 6px 0; - margin-bottom: 4px; - white-space: nowrap; - - display: flex; - align-items: center; - flex-direction: row; - justify-content: space-between; - - svg { - cursor: pointer; - } - - &:first-child { - margin-top: 0; - padding-top: 0; - } - - ${ props => props.variant === 'uniform' && css` - padding-top: 6px; - ` } +const CategoryBase = styled.div<Pick<Props, "variant">>` + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + + margin-top: 4px; + padding: 6px 0; + margin-bottom: 4px; + white-space: nowrap; + + display: flex; + align-items: center; + flex-direction: row; + justify-content: space-between; + + svg { + cursor: pointer; + } + + &:first-child { + margin-top: 0; + padding-top: 0; + } + + ${(props) => + props.variant === "uniform" && + css` + padding-top: 6px; + `} `; -type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as' | 'action'> & { - text: Children; - action?: () => void; - variant?: 'default' | 'uniform'; -} +type Props = Omit< + JSX.HTMLAttributes<HTMLDivElement>, + "children" | "as" | "action" +> & { + text: Children; + action?: () => void; + variant?: "default" | "uniform"; +}; export default function Category(props: Props) { - let { text, action, ...otherProps } = props; - - return ( - <CategoryBase {...otherProps}> - {text} - {action && ( - <Plus size={16} onClick={action} /> - )} - </CategoryBase> - ); -}; + let { text, action, ...otherProps } = props; + + return ( + <CategoryBase {...otherProps}> + {text} + {action && <Plus size={16} onClick={action} />} + </CategoryBase> + ); +} diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx index 7fad94d8adccb67348effc20a8f7867804b797ab..44d80114fff465e580bc6ff43e0b79eafc890689 100644 --- a/src/components/ui/Checkbox.tsx +++ b/src/components/ui/Checkbox.tsx @@ -1,107 +1,108 @@ import { Check } from "@styled-icons/boxicons-regular"; -import { Children } from "../../types/Preact"; import styled, { css } from "styled-components"; +import { Children } from "../../types/Preact"; + const CheckboxBase = styled.label` - margin-top: 20px; - gap: 4px; - z-index: 1; - display: flex; - border-radius: 4px; - align-items: center; + margin-top: 20px; + gap: 4px; + z-index: 1; + display: flex; + border-radius: 4px; + align-items: center; - cursor: pointer; - font-size: 18px; - user-select: none; + cursor: pointer; + font-size: 18px; + user-select: none; - transition: 0.2s ease all; + transition: 0.2s ease all; - input { - display: none; - } + input { + display: none; + } - &:hover { - .check { - background: var(--background); - } - } + &:hover { + .check { + background: var(--background); + } + } - &[disabled] { - opacity: .5; - cursor: not-allowed; + &[disabled] { + opacity: 0.5; + cursor: not-allowed; - &:hover { - background: unset; - } - } + &:hover { + background: unset; + } + } `; const CheckboxContent = styled.span` - display: flex; - flex-grow: 1; - font-size: 1rem; - font-weight: 600; - flex-direction: column; + display: flex; + flex-grow: 1; + font-size: 1rem; + font-weight: 600; + flex-direction: column; `; const CheckboxDescription = styled.span` - font-size: .75rem; - font-weight: 400; - color: var(--secondary-foreground); + font-size: 0.75rem; + font-weight: 400; + color: var(--secondary-foreground); `; const Checkmark = styled.div<{ checked: boolean }>` - margin: 4px; - width: 24px; - height: 24px; - display: grid; - flex-shrink: 0; - border-radius: 4px; - place-items: center; - transition: 0.2s ease all; - background: var(--secondary-background); + margin: 4px; + width: 24px; + height: 24px; + display: grid; + flex-shrink: 0; + border-radius: 4px; + place-items: center; + transition: 0.2s ease all; + background: var(--secondary-background); - svg { - color: var(--secondary-background); - } + svg { + color: var(--secondary-background); + } - ${(props) => - props.checked && - css` - background: var(--accent) !important; - `} + ${(props) => + props.checked && + css` + background: var(--accent) !important; + `} `; export interface CheckboxProps { - checked: boolean; - disabled?: boolean; - className?: string; - children: Children; - description?: Children; - onChange: (state: boolean) => void; + checked: boolean; + disabled?: boolean; + className?: string; + children: Children; + description?: Children; + onChange: (state: boolean) => void; } export default function Checkbox(props: CheckboxProps) { - return ( - <CheckboxBase disabled={props.disabled} className={props.className}> - <CheckboxContent> - <span>{props.children}</span> - {props.description && ( - <CheckboxDescription> - {props.description} - </CheckboxDescription> - )} - </CheckboxContent> - <input - type="checkbox" - checked={props.checked} - onChange={() => - !props.disabled && props.onChange(!props.checked) - } - /> - <Checkmark checked={props.checked} className="check"> - <Check size={20} /> - </Checkmark> - </CheckboxBase> - ); + return ( + <CheckboxBase disabled={props.disabled} className={props.className}> + <CheckboxContent> + <span>{props.children}</span> + {props.description && ( + <CheckboxDescription> + {props.description} + </CheckboxDescription> + )} + </CheckboxContent> + <input + type="checkbox" + checked={props.checked} + onChange={() => + !props.disabled && props.onChange(!props.checked) + } + /> + <Checkmark checked={props.checked} className="check"> + <Check size={20} /> + </Checkmark> + </CheckboxBase> + ); } diff --git a/src/components/ui/ColourSwatches.tsx b/src/components/ui/ColourSwatches.tsx index 46a473fc80ca6077b82daf219088d95fad4b49b1..b0bd781ef1906a121f818a509e5258506a68455b 100644 --- a/src/components/ui/ColourSwatches.tsx +++ b/src/components/ui/ColourSwatches.tsx @@ -1,130 +1,127 @@ -import { useRef } from "preact/hooks"; import { Check } from "@styled-icons/boxicons-regular"; import { Palette } from "@styled-icons/boxicons-solid"; import styled, { css } from "styled-components"; +import { useRef } from "preact/hooks"; + interface Props { - value: string; - onChange: (value: string) => void; + value: string; + onChange: (value: string) => void; } const presets = [ - [ - "#7B68EE", - "#3498DB", - "#1ABC9C", - "#F1C40F", - "#FF7F50", - "#FD6671", - "#E91E63", - "#D468EE", - ], - [ - "#594CAD", - "#206694", - "#11806A", - "#C27C0E", - "#CD5B45", - "#FF424F", - "#AD1457", - "#954AA8", - ], + [ + "#7B68EE", + "#3498DB", + "#1ABC9C", + "#F1C40F", + "#FF7F50", + "#FD6671", + "#E91E63", + "#D468EE", + ], + [ + "#594CAD", + "#206694", + "#11806A", + "#C27C0E", + "#CD5B45", + "#FF424F", + "#AD1457", + "#954AA8", + ], ]; const SwatchesBase = styled.div` - gap: 8px; - display: flex; + gap: 8px; + display: flex; - input { - opacity: 0; - margin-top: 44px; - position: absolute; - pointer-events: none; - } + input { + opacity: 0; + margin-top: 44px; + position: absolute; + pointer-events: none; + } `; const Swatch = styled.div<{ type: "small" | "large"; colour: string }>` - flex-shrink: 0; - cursor: pointer; - border-radius: 4px; - background-color: ${(props) => props.colour}; + flex-shrink: 0; + cursor: pointer; + border-radius: 4px; + background-color: ${(props) => props.colour}; - display: grid; - place-items: center; + display: grid; + place-items: center; - &:hover { - border: 3px solid var(--foreground); - transition: border ease-in-out 0.07s; - } + &:hover { + border: 3px solid var(--foreground); + transition: border ease-in-out 0.07s; + } - svg { - color: white; - } + svg { + color: white; + } - ${(props) => - props.type === "small" - ? css` - width: 30px; - height: 30px; + ${(props) => + props.type === "small" + ? css` + width: 30px; + height: 30px; - svg { - /*stroke-width: 2;*/ - } - ` - : css` - width: 68px; - height: 68px; - `} + svg { + /*stroke-width: 2;*/ + } + ` + : css` + width: 68px; + height: 68px; + `} `; const Rows = styled.div` - gap: 8px; - display: flex; - flex-direction: column; + gap: 8px; + display: flex; + flex-direction: column; - > div { - gap: 8px; - display: flex; - flex-direction: row; - } + > div { + gap: 8px; + display: flex; + flex-direction: row; + } `; export default function ColourSwatches({ value, onChange }: Props) { - const ref = useRef<HTMLInputElement>(); + const ref = useRef<HTMLInputElement>(); - return ( - <SwatchesBase> - <Swatch - colour={value} - type="large" - onClick={() => ref.current.click()} - > - <Palette size={32} /> - </Swatch> - <input - type="color" - value={value} - ref={ref} - onChange={(ev) => onChange(ev.currentTarget.value)} - /> - <Rows> - {presets.map((row, i) => ( - <div key={i}> - {row.map((swatch, i) => ( - <Swatch - colour={swatch} - type="small" - key={i} - onClick={() => onChange(swatch)} - > - {swatch === value && ( - <Check size={18} /> - )} - </Swatch> - ))} - </div> - ))} - </Rows> - </SwatchesBase> - ); + return ( + <SwatchesBase> + <Swatch + colour={value} + type="large" + onClick={() => ref.current.click()}> + <Palette size={32} /> + </Swatch> + <input + type="color" + value={value} + ref={ref} + onChange={(ev) => onChange(ev.currentTarget.value)} + /> + <Rows> + {presets.map((row, i) => ( + <div key={i}> + {row.map((swatch, i) => ( + <Swatch + colour={swatch} + type="small" + key={i} + onClick={() => onChange(swatch)}> + {swatch === value && <Check size={18} />} + </Swatch> + ))} + </div> + ))} + </Rows> + </SwatchesBase> + ); } diff --git a/src/components/ui/ComboBox.tsx b/src/components/ui/ComboBox.tsx index 989f0787b351c5b9e8c632494956de7e14ade81f..55911247d22fa407fa201c665a4e1267fc64ecfa 100644 --- a/src/components/ui/ComboBox.tsx +++ b/src/components/ui/ComboBox.tsx @@ -1,20 +1,20 @@ import styled from "styled-components"; export default styled.select` - padding: 8px; - border-radius: 6px; - font-family: inherit; - color: var(--secondary-foreground); - background: var(--secondary-background); - font-size: .875rem; - border: none; - outline: 2px solid transparent; - transition: outline-color 0.2s ease-in-out; - transition: box-shadow .3s; - cursor: pointer; - width: 100%; + padding: 8px; + border-radius: 6px; + font-family: inherit; + color: var(--secondary-foreground); + background: var(--secondary-background); + font-size: 0.875rem; + border: none; + outline: 2px solid transparent; + transition: outline-color 0.2s ease-in-out; + transition: box-shadow 0.3s; + cursor: pointer; + width: 100%; - &:focus { - box-shadow: 0 0 0 2pt var(--accent); - } + &:focus { + box-shadow: 0 0 0 2pt var(--accent); + } `; diff --git a/src/components/ui/DateDivider.tsx b/src/components/ui/DateDivider.tsx index 5f6e1a0af05e73af8347794bc3b25923e31cce95..4f2a9dd5bca12b2f53eab4e5599e330926887055 100644 --- a/src/components/ui/DateDivider.tsx +++ b/src/components/ui/DateDivider.tsx @@ -2,47 +2,47 @@ import dayjs from "dayjs"; import styled, { css } from "styled-components"; const Base = styled.div<{ unread?: boolean }>` - height: 0; - display: flex; - user-select: none; - align-items: center; - margin: 17px 12px 5px; - border-top: thin solid var(--tertiary-foreground); + height: 0; + display: flex; + user-select: none; + align-items: center; + margin: 17px 12px 5px; + border-top: thin solid var(--tertiary-foreground); - time { - margin-top: -2px; - font-size: .6875rem; - line-height: .6875rem; - padding: 2px 5px 2px 0; - color: var(--tertiary-foreground); - background: var(--primary-background); - } + time { + margin-top: -2px; + font-size: 0.6875rem; + line-height: 0.6875rem; + padding: 2px 5px 2px 0; + color: var(--tertiary-foreground); + background: var(--primary-background); + } - ${ props => props.unread && css` - border-top: thin solid var(--accent); - ` } + ${(props) => + props.unread && + css` + border-top: thin solid var(--accent); + `} `; const Unread = styled.div` - background: var(--accent); - color: white; - padding: 5px 8px; - border-radius: 60px; - font-weight: 600; + background: var(--accent); + color: white; + padding: 5px 8px; + border-radius: 60px; + font-weight: 600; `; interface Props { - date: Date; - unread?: boolean; + date: Date; + unread?: boolean; } export default function DateDivider(props: Props) { - return ( - <Base unread={props.unread}> - { props.unread && <Unread>NEW</Unread> } - <time> - { dayjs(props.date).format("LL") } - </time> - </Base> - ); + return ( + <Base unread={props.unread}> + {props.unread && <Unread>NEW</Unread>} + <time>{dayjs(props.date).format("LL")}</time> + </Base> + ); } diff --git a/src/components/ui/Details.tsx b/src/components/ui/Details.tsx index f06a67f70a3604faf69a2586d57e35d486d0916c..58ed693514a0f87e696b6b88777287343883a6d2 100644 --- a/src/components/ui/Details.tsx +++ b/src/components/ui/Details.tsx @@ -1,68 +1,74 @@ import styled, { css } from "styled-components"; -export default styled.details<{ sticky?: boolean, large?: boolean }>` - summary { - ${ props => props.sticky && css` - top: -1px; - z-index: 10; - position: sticky; - ` } +export default styled.details<{ sticky?: boolean; large?: boolean }>` + summary { + ${(props) => + props.sticky && + css` + top: -1px; + z-index: 10; + position: sticky; + `} - ${ props => props.large && css` - /*padding: 5px 0;*/ - background: var(--primary-background); - color: var(--secondary-foreground); + ${(props) => + props.large && + css` + /*padding: 5px 0;*/ + background: var(--primary-background); + color: var(--secondary-foreground); - .padding { /*TOFIX: make this applicable only for the friends list menu, DO NOT REMOVE.*/ - display: flex; - align-items: center; - padding: 5px 0; - margin: 0.8em 0px 0.4em; - cursor: pointer; - } - ` } + .padding { + /*TOFIX: make this applicable only for the friends list menu, DO NOT REMOVE.*/ + display: flex; + align-items: center; + padding: 5px 0; + margin: 0.8em 0px 0.4em; + cursor: pointer; + } + `} outline: none; - cursor: pointer; - list-style: none; - align-items: center; - transition: .2s opacity; - - font-size: 12px; - font-weight: 600; - text-transform: uppercase; + cursor: pointer; + list-style: none; + align-items: center; + transition: 0.2s opacity; - &::marker, &::-webkit-details-marker { - display: none; - } + font-size: 12px; + font-weight: 600; + text-transform: uppercase; - .title { - flex-grow: 1; - margin-top: 1px; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } + &::marker, + &::-webkit-details-marker { + display: none; + } - .padding { - display: flex; - align-items: center; + .title { + flex-grow: 1; + margin-top: 1px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } - > svg { - flex-shrink: 0; - margin-inline-end: 4px; - transition: .2s ease transform; - } - } - } + .padding { + display: flex; + align-items: center; - &:not([open]) { - summary { - opacity: .7; - } - - summary svg { - transform: rotateZ(-90deg); - } - } + > svg { + flex-shrink: 0; + margin-inline-end: 4px; + transition: 0.2s ease transform; + } + } + } + + &:not([open]) { + summary { + opacity: 0.7; + } + + summary svg { + transform: rotateZ(-90deg); + } + } `; diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx index d4e0e3f90f605d2857f5c10b2fe1affc81cc3857..e935b80a99adbcb0de8affbed6a255b546ca49eb 100644 --- a/src/components/ui/Header.tsx +++ b/src/components/ui/Header.tsx @@ -1,51 +1,57 @@ import styled, { css } from "styled-components"; interface Props { - borders?: boolean; - background?: boolean; - placement: 'primary' | 'secondary' + borders?: boolean; + background?: boolean; + placement: "primary" | "secondary"; } export default styled.div<Props>` - gap: 6px; - height: 48px; - flex: 0 auto; - display: flex; - flex-shrink: 0; - padding: 0 16px; - font-weight: 600; - user-select: none; - align-items: center; - - background-size: cover !important; - background-position: center !important; - background-color: var(--primary-header); - - svg { - flex-shrink: 0; - } - - /*@media only screen and (max-width: 768px) { + gap: 6px; + height: 48px; + flex: 0 auto; + display: flex; + flex-shrink: 0; + padding: 0 16px; + font-weight: 600; + user-select: none; + align-items: center; + + background-size: cover !important; + background-position: center !important; + background-color: var(--primary-header); + + svg { + flex-shrink: 0; + } + + /*@media only screen and (max-width: 768px) { padding: 0 12px; }*/ - @media (pointer: coarse) { - height: 56px; - } - - ${ props => props.background && css` - height: 120px !important; - align-items: flex-end; - - text-shadow: 0px 0px 1px black; - ` } - - ${ props => props.placement === 'secondary' && css` - background-color: var(--secondary-header); - padding: 14px; - ` } - - ${ props => props.borders && css` - border-start-start-radius: 8px; - ` } + @media (pointer: coarse) { + height: 56px; + } + + ${(props) => + props.background && + css` + height: 120px !important; + align-items: flex-end; + + text-shadow: 0px 0px 1px black; + `} + + ${(props) => + props.placement === "secondary" && + css` + background-color: var(--secondary-header); + padding: 14px; + `} + + ${(props) => + props.borders && + css` + border-start-start-radius: 8px; + `} `; diff --git a/src/components/ui/IconButton.tsx b/src/components/ui/IconButton.tsx index bf946abc275773592ce1654ad7e4876f3adaae8a..3044ec5a6c64ac1c817e282fe8f2bfed36c045c8 100644 --- a/src/components/ui/IconButton.tsx +++ b/src/components/ui/IconButton.tsx @@ -1,44 +1,46 @@ import styled, { css } from "styled-components"; interface Props { - type?: 'default' | 'circle' + type?: "default" | "circle"; } const normal = `var(--secondary-foreground)`; const hover = `var(--foreground)`; export default styled.div<Props>` - z-index: 1; - display: grid; - cursor: pointer; - place-items: center; - transition: .1s ease background-color; - - fill: ${normal}; - color: ${normal}; - /*stroke: ${normal};*/ - - a { - color: ${normal}; - } - - &:hover { - fill: ${hover}; - color: ${hover}; - /*stroke: ${hover};*/ - - a { - color: ${hover}; - } - } - - ${ props => props.type === 'circle' && css` - padding: 4px; - border-radius: 50%; - background-color: var(--secondary-header); - - &:hover { - background-color: var(--primary-header); - } - ` } + z-index: 1; + display: grid; + cursor: pointer; + place-items: center; + transition: 0.1s ease background-color; + + fill: ${normal}; + color: ${normal}; + /*stroke: ${normal};*/ + + a { + color: ${normal}; + } + + &:hover { + fill: ${hover}; + color: ${hover}; + /*stroke: ${hover};*/ + + a { + color: ${hover}; + } + } + + ${(props) => + props.type === "circle" && + css` + padding: 4px; + border-radius: 50%; + background-color: var(--secondary-header); + + &:hover { + background-color: var(--primary-header); + } + `} `; diff --git a/src/components/ui/InputBox.tsx b/src/components/ui/InputBox.tsx index 75e3510b9c822161457f4dbfd7cf84019adec716..b6dcbb84bf0df256fa6eaae24a06fba521557cd6 100644 --- a/src/components/ui/InputBox.tsx +++ b/src/components/ui/InputBox.tsx @@ -1,39 +1,39 @@ import styled, { css } from "styled-components"; interface Props { - readonly contrast?: boolean; + readonly contrast?: boolean; } export default styled.input<Props>` - z-index: 1; - padding: 8px 16px; - border-radius: 6px; - - font-family: inherit; - color: var(--foreground); - background: var(--primary-background); - transition: 0.2s ease background-color; - - border: none; - outline: 2px solid transparent; - transition: outline-color 0.2s ease-in-out; - - &:hover { - background: var(--secondary-background); - } - - &:focus { - outline: 2px solid var(--accent); - } - - ${(props) => - props.contrast && - css` - color: var(--secondary-foreground); - background: var(--secondary-background); - - &:hover { - background: var(--hover); - } - `} + z-index: 1; + padding: 8px 16px; + border-radius: 6px; + + font-family: inherit; + color: var(--foreground); + background: var(--primary-background); + transition: 0.2s ease background-color; + + border: none; + outline: 2px solid transparent; + transition: outline-color 0.2s ease-in-out; + + &:hover { + background: var(--secondary-background); + } + + &:focus { + outline: 2px solid var(--accent); + } + + ${(props) => + props.contrast && + css` + color: var(--secondary-foreground); + background: var(--secondary-background); + + &:hover { + background: var(--hover); + } + `} `; diff --git a/src/components/ui/LineDivider.tsx b/src/components/ui/LineDivider.tsx index 58a9c7a45817feddd3bd9eb9aa9bc1b73dc2dd63..0ffd1e7c9532028b58181a06240d848a006649a3 100644 --- a/src/components/ui/LineDivider.tsx +++ b/src/components/ui/LineDivider.tsx @@ -1,9 +1,9 @@ import styled from "styled-components"; export default styled.div` - height: 0px; - opacity: 0.6; - flex-shrink: 0; - margin: 8px 10px; - border-top: 1px solid var(--tertiary-foreground); + height: 0px; + opacity: 0.6; + flex-shrink: 0; + margin: 8px 10px; + border-top: 1px solid var(--tertiary-foreground); `; diff --git a/src/components/ui/Masks.tsx b/src/components/ui/Masks.tsx index eba85108a99bd53b8845392b888f1a9132579150..d8cadc71d08db113c242a345a0210137e846afb9 100644 --- a/src/components/ui/Masks.tsx +++ b/src/components/ui/Masks.tsx @@ -1,22 +1,22 @@ // This file must be imported and used at least once for SVG masks. export default function Masks() { - return ( - <svg width={0} height={0} style={{ position: 'fixed' }}> - <defs> - <mask id="server"> - <rect x="0" y="0" width="32" height="32" fill="white" /> - <circle cx="27" cy="5" r="7" fill={"black"} /> - </mask> - <mask id="user"> - <rect x="0" y="0" width="32" height="32" fill="white" /> - <circle cx="27" cy="27" r="7" fill={"black"} /> - </mask> - <mask id="overlap"> - <rect x="0" y="0" width="32" height="32" fill="white" /> - <circle cx="32" cy="16" r="18" fill={"black"} /> - </mask> - </defs> - </svg> - ) -} \ No newline at end of file + return ( + <svg width={0} height={0} style={{ position: "fixed" }}> + <defs> + <mask id="server"> + <rect x="0" y="0" width="32" height="32" fill="white" /> + <circle cx="27" cy="5" r="7" fill={"black"} /> + </mask> + <mask id="user"> + <rect x="0" y="0" width="32" height="32" fill="white" /> + <circle cx="27" cy="27" r="7" fill={"black"} /> + </mask> + <mask id="overlap"> + <rect x="0" y="0" width="32" height="32" fill="white" /> + <circle cx="32" cy="16" r="18" fill={"black"} /> + </mask> + </defs> + </svg> + ); +} diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 6926508780f3c50cc3a7c5c15d94de71aa15e44c..57a43bf715581094d4a2ac25b934f180fae9da28 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -1,8 +1,10 @@ -import Button from "./Button"; +import styled, { css, keyframes } from "styled-components"; + import classNames from "classnames"; -import { Children } from "../../types/Preact"; import { createPortal, useEffect } from "preact/compat"; -import styled, { css, keyframes } from "styled-components"; + +import { Children } from "../../types/Preact"; +import Button from "./Button"; const open = keyframes` 0% {opacity: 0;} @@ -17,168 +19,181 @@ const zoomIn = keyframes` `; const ModalBase = styled.div` - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 9999; - position: fixed; - max-height: 100%; - user-select: none; - - animation-name: ${open}; - animation-duration: 0.2s; - - display: grid; - overflow-y: auto; - place-items: center; - - color: var(--foreground); - background: rgba(0, 0, 0, 0.8); + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 9999; + position: fixed; + max-height: 100%; + user-select: none; + + animation-name: ${open}; + animation-duration: 0.2s; + + display: grid; + overflow-y: auto; + place-items: center; + + color: var(--foreground); + background: rgba(0, 0, 0, 0.8); `; const ModalContainer = styled.div` - overflow: hidden; - border-radius: 8px; - max-width: calc(100vw - 20px); + overflow: hidden; + border-radius: 8px; + max-width: calc(100vw - 20px); - animation-name: ${zoomIn}; - animation-duration: 0.25s; - animation-timing-function: cubic-bezier(.3,.3,.18,1.1); + animation-name: ${zoomIn}; + animation-duration: 0.25s; + animation-timing-function: cubic-bezier(0.3, 0.3, 0.18, 1.1); `; -const ModalContent = styled.div<{ [key in 'attachment' | 'noBackground' | 'border' | 'padding']?: boolean }>` - border-radius: 8px; - text-overflow: ellipsis; - - h3 { - margin-top: 0; - } - - form { - display: flex; - flex-direction: column; - } - - ${ props => !props.noBackground && css` - background: var(--secondary-header); - ` } - - ${ props => props.padding && css` - padding: 1.5em; - ` } - - ${ props => props.attachment && css` - border-radius: 8px 8px 0 0; - ` } - - ${ props => props.border && css` - border-radius: 10px; - border: 2px solid var(--secondary-background); - ` } +const ModalContent = styled.div< + { [key in "attachment" | "noBackground" | "border" | "padding"]?: boolean } +>` + border-radius: 8px; + text-overflow: ellipsis; + + h3 { + margin-top: 0; + } + + form { + display: flex; + flex-direction: column; + } + + ${(props) => + !props.noBackground && + css` + background: var(--secondary-header); + `} + + ${(props) => + props.padding && + css` + padding: 1.5em; + `} + + ${(props) => + props.attachment && + css` + border-radius: 8px 8px 0 0; + `} + + ${(props) => + props.border && + css` + border-radius: 10px; + border: 2px solid var(--secondary-background); + `} `; const ModalActions = styled.div` - gap: 8px; - display: flex; - flex-direction: row-reverse; + gap: 8px; + display: flex; + flex-direction: row-reverse; - padding: 1em 1.5em; - border-radius: 0 0 8px 8px; - background: var(--secondary-background); + padding: 1em 1.5em; + border-radius: 0 0 8px 8px; + background: var(--secondary-background); `; export interface Action { - text: Children; - onClick: () => void; - confirmation?: boolean; - contrast?: boolean; - error?: boolean; + text: Children; + onClick: () => void; + confirmation?: boolean; + contrast?: boolean; + error?: boolean; } interface Props { - children?: Children; - title?: Children; - - disallowClosing?: boolean; - noBackground?: boolean; - dontModal?: boolean; - padding?: boolean; - - onClose: () => void; - actions?: Action[]; - disabled?: boolean; - border?: boolean; - visible: boolean; + children?: Children; + title?: Children; + + disallowClosing?: boolean; + noBackground?: boolean; + dontModal?: boolean; + padding?: boolean; + + onClose: () => void; + actions?: Action[]; + disabled?: boolean; + border?: boolean; + visible: boolean; } export default function Modal(props: Props) { - if (!props.visible) return null; - - let content = ( - <ModalContent - attachment={!!props.actions} - noBackground={props.noBackground} - border={props.border} - padding={props.padding ?? !props.dontModal}> - {props.title && <h3>{props.title}</h3>} - {props.children} - </ModalContent> - ); - - if (props.dontModal) { - return content; - } - - useEffect(() => { - if (props.disallowClosing) return; - - function keyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - props.onClose(); - } - } - - document.body.addEventListener("keydown", keyDown); - return () => document.body.removeEventListener("keydown", keyDown); - }, [ props.disallowClosing, props.onClose ]); - - let confirmationAction = props.actions?.find(action => action.confirmation); - useEffect(() => { - if (!confirmationAction) return; - - // ! FIXME: this may be done better if we - // ! can focus the button although that - // ! doesn't seem to work... - function keyDown(e: KeyboardEvent) { - if (e.key === "Enter") { - confirmationAction!.onClick(); - } - } - - document.body.addEventListener("keydown", keyDown); - return () => document.body.removeEventListener("keydown", keyDown); - }, [ confirmationAction ]); - - return createPortal( - <ModalBase onClick={(!props.disallowClosing && props.onClose) || undefined}> - <ModalContainer onClick={e => (e.cancelBubble = true)}> - {content} - {props.actions && ( - <ModalActions> - {props.actions.map(x => ( - <Button - contrast={x.contrast ?? true} - error={x.error ?? false} - onClick={x.onClick} - disabled={props.disabled}> - {x.text} - </Button> - ))} - </ModalActions> - )} - </ModalContainer> - </ModalBase>, - document.body - ); + if (!props.visible) return null; + + let content = ( + <ModalContent + attachment={!!props.actions} + noBackground={props.noBackground} + border={props.border} + padding={props.padding ?? !props.dontModal}> + {props.title && <h3>{props.title}</h3>} + {props.children} + </ModalContent> + ); + + if (props.dontModal) { + return content; + } + + useEffect(() => { + if (props.disallowClosing) return; + + function keyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + props.onClose(); + } + } + + document.body.addEventListener("keydown", keyDown); + return () => document.body.removeEventListener("keydown", keyDown); + }, [props.disallowClosing, props.onClose]); + + let confirmationAction = props.actions?.find( + (action) => action.confirmation, + ); + useEffect(() => { + if (!confirmationAction) return; + + // ! FIXME: this may be done better if we + // ! can focus the button although that + // ! doesn't seem to work... + function keyDown(e: KeyboardEvent) { + if (e.key === "Enter") { + confirmationAction!.onClick(); + } + } + + document.body.addEventListener("keydown", keyDown); + return () => document.body.removeEventListener("keydown", keyDown); + }, [confirmationAction]); + + return createPortal( + <ModalBase + onClick={(!props.disallowClosing && props.onClose) || undefined}> + <ModalContainer onClick={(e) => (e.cancelBubble = true)}> + {content} + {props.actions && ( + <ModalActions> + {props.actions.map((x) => ( + <Button + contrast={x.contrast ?? true} + error={x.error ?? false} + onClick={x.onClick} + disabled={props.disabled}> + {x.text} + </Button> + ))} + </ModalActions> + )} + </ModalContainer> + </ModalBase>, + document.body, + ); } diff --git a/src/components/ui/Overline.tsx b/src/components/ui/Overline.tsx index 171590770c6ae480838b913a71f49260a60bea50..8dc0b54e684036e9f02a4cce5cf057b148e56af2 100644 --- a/src/components/ui/Overline.tsx +++ b/src/components/ui/Overline.tsx @@ -1,58 +1,64 @@ import styled, { css } from "styled-components"; + +import { Text } from "preact-i18n"; + import { Children } from "../../types/Preact"; -import { Text } from 'preact-i18n'; - -type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as'> & { - error?: string; - block?: boolean; - spaced?: boolean; - children?: Children; - type?: "default" | "subtle" | "error"; -} + +type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, "children" | "as"> & { + error?: string; + block?: boolean; + spaced?: boolean; + children?: Children; + type?: "default" | "subtle" | "error"; +}; const OverlineBase = styled.div<Omit<Props, "children" | "error">>` - display: inline; - margin: 0.4em 0; + display: inline; + margin: 0.4em 0; - ${ props => props.spaced && css` - margin-top: 0.8em; - ` } + ${(props) => + props.spaced && + css` + margin-top: 0.8em; + `} - font-size: 14px; - font-weight: 600; - color: var(--foreground); - text-transform: uppercase; + font-size: 14px; + font-weight: 600; + color: var(--foreground); + text-transform: uppercase; - ${(props) => - props.type === "subtle" && - css` - font-size: 12px; - color: var(--secondary-foreground); - `} + ${(props) => + props.type === "subtle" && + css` + font-size: 12px; + color: var(--secondary-foreground); + `} - ${(props) => - props.type === "error" && - css` - font-size: 12px; - font-weight: 400; - color: var(--error); - `} + ${(props) => + props.type === "error" && + css` + font-size: 12px; + font-weight: 400; + color: var(--error); + `} ${(props) => - props.block && - css` - display: block; - `} + props.block && + css` + display: block; + `} `; export default function Overline(props: Props) { - return ( - <OverlineBase {...props}> - {props.children} - {props.children && props.error && <> · </>} - {props.error && <Overline type="error"> - <Text id={`error.${props.error}`}>{props.error}</Text> - </Overline>} - </OverlineBase> - ); + return ( + <OverlineBase {...props}> + {props.children} + {props.children && props.error && <> · </>} + {props.error && ( + <Overline type="error"> + <Text id={`error.${props.error}`}>{props.error}</Text> + </Overline> + )} + </OverlineBase> + ); } diff --git a/src/components/ui/Preloader.tsx b/src/components/ui/Preloader.tsx index 7e3ec3fcda41739da3f21da363f692a212d1aeda..a9e70ab4e1224fd3fb903e14d35b97aadd7f1729 100644 --- a/src/components/ui/Preloader.tsx +++ b/src/components/ui/Preloader.tsx @@ -21,83 +21,83 @@ const prRing = keyframes` `; const PreloaderBase = styled.div` - width: 100%; - height: 100%; - - display: grid; - place-items: center; - - .spinner { - width: 58px; - display: flex; - text-align: center; - margin: 100px auto 0; - justify-content: space-between; - } - - .spinner > div { - width: 14px; - height: 14px; - background-color: var(--tertiary-foreground); - - border-radius: 100%; - display: inline-block; - animation: ${skSpinner} 1.4s infinite ease-in-out both; - } - - .spinner div:nth-child(1) { - animation-delay: -0.32s; - } - - .spinner div:nth-child(2) { - animation-delay: -0.16s; - } - - .ring { - display: inline-block; - position: relative; - width: 48px; - height: 52px; - } - - .ring div { - width: 32px; - margin: 8px; - height: 32px; - display: block; - position: absolute; - border-radius: 50%; - box-sizing: border-box; - border: 2px solid #fff; - animation: ${prRing} 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; - border-color: #fff transparent transparent transparent; - } - - .ring div:nth-child(1) { - animation-delay: -0.45s; - } - - .ring div:nth-child(2) { - animation-delay: -0.3s; - } - - .ring div:nth-child(3) { - animation-delay: -0.15s; - } + width: 100%; + height: 100%; + + display: grid; + place-items: center; + + .spinner { + width: 58px; + display: flex; + text-align: center; + margin: 100px auto 0; + justify-content: space-between; + } + + .spinner > div { + width: 14px; + height: 14px; + background-color: var(--tertiary-foreground); + + border-radius: 100%; + display: inline-block; + animation: ${skSpinner} 1.4s infinite ease-in-out both; + } + + .spinner div:nth-child(1) { + animation-delay: -0.32s; + } + + .spinner div:nth-child(2) { + animation-delay: -0.16s; + } + + .ring { + display: inline-block; + position: relative; + width: 48px; + height: 52px; + } + + .ring div { + width: 32px; + margin: 8px; + height: 32px; + display: block; + position: absolute; + border-radius: 50%; + box-sizing: border-box; + border: 2px solid #fff; + animation: ${prRing} 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; + border-color: #fff transparent transparent transparent; + } + + .ring div:nth-child(1) { + animation-delay: -0.45s; + } + + .ring div:nth-child(2) { + animation-delay: -0.3s; + } + + .ring div:nth-child(3) { + animation-delay: -0.15s; + } `; interface Props { - type: 'spinner' | 'ring' + type: "spinner" | "ring"; } export default function Preloader({ type }: Props) { - return ( - <PreloaderBase> - <div class={type}> - <div /> - <div /> - <div /> - </div> - </PreloaderBase> - ); + return ( + <PreloaderBase> + <div class={type}> + <div /> + <div /> + <div /> + </div> + </PreloaderBase> + ); } diff --git a/src/components/ui/Radio.tsx b/src/components/ui/Radio.tsx index e988bbc94cd113034f2cce09f714ed3df8d20dc5..7bd220c488f8a6a879f6d01bcafbb4560ec65d7a 100644 --- a/src/components/ui/Radio.tsx +++ b/src/components/ui/Radio.tsx @@ -1,111 +1,111 @@ -import { Children } from "../../types/Preact"; -import styled, { css } from "styled-components"; import { Circle } from "@styled-icons/boxicons-regular"; +import styled, { css } from "styled-components"; + +import { Children } from "../../types/Preact"; interface Props { - children: Children; - description?: Children; + children: Children; + description?: Children; - checked: boolean; - disabled?: boolean; - onSelect: () => void; + checked: boolean; + disabled?: boolean; + onSelect: () => void; } interface BaseProps { - selected: boolean; + selected: boolean; } const RadioBase = styled.label<BaseProps>` - gap: 4px; - z-index: 1; - padding: 4px; - display: flex; - cursor: pointer; - align-items: center; - - font-size: 1rem; - font-weight: 600; - user-select: none; - border-radius: 4px; - transition: 0.2s ease all; - - &:hover { - background: var(--hover); - } - - > input { - display: none; - } - - > div { - margin: 4px; - width: 24px; - height: 24px; - display: grid; - border-radius: 50%; - place-items: center; - background: var(--foreground); - - svg { - color: var(--foreground); - /*stroke-width: 2;*/ - } - } - - ${(props) => - props.selected && - css` - color: white; - cursor: default; - background: var(--accent); - - > div { - background: white; - } - - > div svg { - color: var(--accent); - } - - &:hover { - background: var(--accent); - } - `} + gap: 4px; + z-index: 1; + padding: 4px; + display: flex; + cursor: pointer; + align-items: center; + + font-size: 1rem; + font-weight: 600; + user-select: none; + border-radius: 4px; + transition: 0.2s ease all; + + &:hover { + background: var(--hover); + } + + > input { + display: none; + } + + > div { + margin: 4px; + width: 24px; + height: 24px; + display: grid; + border-radius: 50%; + place-items: center; + background: var(--foreground); + + svg { + color: var(--foreground); + /*stroke-width: 2;*/ + } + } + + ${(props) => + props.selected && + css` + color: white; + cursor: default; + background: var(--accent); + + > div { + background: white; + } + + > div svg { + color: var(--accent); + } + + &:hover { + background: var(--accent); + } + `} `; const RadioDescription = styled.span<BaseProps>` - font-size: 0.8em; - font-weight: 400; - color: var(--secondary-foreground); - - ${(props) => - props.selected && - css` - color: white; - `} + font-size: 0.8em; + font-weight: 400; + color: var(--secondary-foreground); + + ${(props) => + props.selected && + css` + color: white; + `} `; export default function Radio(props: Props) { - return ( - <RadioBase - selected={props.checked} - disabled={props.disabled} - onClick={() => - !props.disabled && props.onSelect && props.onSelect() - } - > - <div> - <Circle size={12} /> - </div> - <input type="radio" checked={props.checked} /> - <span> - <span>{props.children}</span> - {props.description && ( - <RadioDescription selected={props.checked}> - {props.description} - </RadioDescription> - )} - </span> - </RadioBase> - ); + return ( + <RadioBase + selected={props.checked} + disabled={props.disabled} + onClick={() => + !props.disabled && props.onSelect && props.onSelect() + }> + <div> + <Circle size={12} /> + </div> + <input type="radio" checked={props.checked} /> + <span> + <span>{props.children}</span> + {props.description && ( + <RadioDescription selected={props.checked}> + {props.description} + </RadioDescription> + )} + </span> + </RadioBase> + ); } diff --git a/src/components/ui/TextArea.tsx b/src/components/ui/TextArea.tsx index 88f5959dff523b78a3f84c8086e0c10db0dc0e9e..3d3f296bfb769fe564a35a131070ab3f360946b6 100644 --- a/src/components/ui/TextArea.tsx +++ b/src/components/ui/TextArea.tsx @@ -1,10 +1,10 @@ import styled, { css } from "styled-components"; export interface TextAreaProps { - code?: boolean; - padding?: number; - lineHeight?: number; - hideBorder?: boolean; + code?: boolean; + padding?: number; + lineHeight?: number; + hideBorder?: boolean; } export const TEXT_AREA_BORDER_WIDTH = 2; @@ -12,37 +12,46 @@ export const DEFAULT_TEXT_AREA_PADDING = 16; export const DEFAULT_LINE_HEIGHT = 20; export default styled.textarea<TextAreaProps>` - width: 100%; - resize: none; - display: block; - color: var(--foreground); - background: var(--secondary-background); - padding: ${ props => props.padding ?? DEFAULT_TEXT_AREA_PADDING }px; - line-height: ${ props => props.lineHeight ?? DEFAULT_LINE_HEIGHT }px; - - ${ props => props.hideBorder && css` - border: none; - ` } - - ${ props => !props.hideBorder && css` - border-radius: 4px; - transition: border-color .2s ease-in-out; - border: ${TEXT_AREA_BORDER_WIDTH}px solid transparent; - ` } + width: 100%; + resize: none; + display: block; + color: var(--foreground); + background: var(--secondary-background); + padding: ${(props) => props.padding ?? DEFAULT_TEXT_AREA_PADDING}px; + line-height: ${(props) => props.lineHeight ?? DEFAULT_LINE_HEIGHT}px; + + ${(props) => + props.hideBorder && + css` + border: none; + `} + + ${(props) => + !props.hideBorder && + css` + border-radius: 4px; + transition: border-color 0.2s ease-in-out; + border: ${TEXT_AREA_BORDER_WIDTH}px solid transparent; + `} &:focus { - outline: none; - - ${ props => !props.hideBorder && css` - border: ${TEXT_AREA_BORDER_WIDTH}px solid var(--accent); - ` } - } - - ${ props => props.code ? css` - font-family: var(--monoscape-font-font), monospace; - ` : css` - font-family: inherit; - ` } - - font-variant-ligatures: var(--ligatures); + outline: none; + + ${(props) => + !props.hideBorder && + css` + border: ${TEXT_AREA_BORDER_WIDTH}px solid var(--accent); + `} + } + + ${(props) => + props.code + ? css` + font-family: var(--monoscape-font-font), monospace; + ` + : css` + font-family: inherit; + `} + + font-variant-ligatures: var(--ligatures); `; diff --git a/src/components/ui/Tip.tsx b/src/components/ui/Tip.tsx index f664a58cac3965f6632a73a437ab295df0ef13f3..0ef02c093b423acd984720203b285a3ea5c61cb3 100644 --- a/src/components/ui/Tip.tsx +++ b/src/components/ui/Tip.tsx @@ -1,65 +1,69 @@ -import { Children } from "../../types/Preact"; -import styled, { css } from "styled-components"; import { InfoCircle } from "@styled-icons/boxicons-regular"; +import styled, { css } from "styled-components"; + +import { Children } from "../../types/Preact"; interface Props { - warning?: boolean - error?: boolean + warning?: boolean; + error?: boolean; } export const Separator = styled.div<Props>` - height: 1px; - width: calc(100% - 10px); - background: var(--secondary-header); - margin: 18px auto; + height: 1px; + width: calc(100% - 10px); + background: var(--secondary-header); + margin: 18px auto; `; export const TipBase = styled.div<Props>` - display: flex; - padding: 12px; - overflow: hidden; - align-items: center; + display: flex; + padding: 12px; + overflow: hidden; + align-items: center; - font-size: 14px; - border-radius: 7px; - background: var(--primary-header); - border: 2px solid var(--secondary-header); + font-size: 14px; + border-radius: 7px; + background: var(--primary-header); + border: 2px solid var(--secondary-header); - a { - cursor: pointer; - &:hover { - text-decoration: underline; - } - } + a { + cursor: pointer; + &:hover { + text-decoration: underline; + } + } - svg { - flex-shrink: 0; - margin-inline-end: 10px; - } + svg { + flex-shrink: 0; + margin-inline-end: 10px; + } - ${ props => props.warning && css` - color: var(--warning); - border: 2px solid var(--warning); - background: var(--secondary-header); - ` } + ${(props) => + props.warning && + css` + color: var(--warning); + border: 2px solid var(--warning); + background: var(--secondary-header); + `} - ${ props => props.error && css` - color: var(--error); - border: 2px solid var(--error); - background: var(--secondary-header); - ` } + ${(props) => + props.error && + css` + color: var(--error); + border: 2px solid var(--error); + background: var(--secondary-header); + `} `; export default function Tip(props: Props & { children: Children }) { - const { children, ...tipProps } = props; - return ( - <> - <Separator /> - <TipBase {...tipProps}> - <InfoCircle size={20} /> - <span>{props.children}</span> - </TipBase> - </> - - ); + const { children, ...tipProps } = props; + return ( + <> + <Separator /> + <TipBase {...tipProps}> + <InfoCircle size={20} /> + <span>{props.children}</span> + </TipBase> + </> + ); } diff --git a/src/context/Locale.tsx b/src/context/Locale.tsx index 6fdff9e216a3173b2e8e6eedac9b9744d9d70d6e..b9200e38f0bc162bfbdfadc33ee011d9d0764f67 100644 --- a/src/context/Locale.tsx +++ b/src/context/Locale.tsx @@ -1,186 +1,217 @@ -import { IntlProvider } from "preact-i18n"; +import dayjs from "dayjs"; +import calendar from "dayjs/plugin/calendar"; +import format from "dayjs/plugin/localizedFormat"; +import update from "dayjs/plugin/updateLocale"; import defaultsDeep from "lodash.defaultsdeep"; -import { connectState } from "../redux/connector"; + +import { IntlProvider } from "preact-i18n"; import { useEffect, useState } from "preact/hooks"; + +import { connectState } from "../redux/connector"; + import definition from "../../external/lang/en.json"; -import dayjs from "dayjs"; -import calendar from "dayjs/plugin/calendar"; -import update from "dayjs/plugin/updateLocale"; -import format from "dayjs/plugin/localizedFormat"; dayjs.extend(calendar); dayjs.extend(format); dayjs.extend(update); export enum Language { - ENGLISH = "en", - - ARABIC = "ar", - AZERBAIJANI = "az", - CZECH = "cs", - GERMAN = "de", - SPANISH = "es", - FINNISH = "fi", - FRENCH = "fr", - HINDI = "hi", - CROATIAN = "hr", - HUNGARIAN = "hu", - INDONESIAN = "id", - LITHUANIAN = "lt", - MACEDONIAN = "mk", - DUTCH = "nl", - POLISH = "pl", - PORTUGUESE_BRAZIL = "pt_BR", - ROMANIAN = "ro", - RUSSIAN = "ru", - SERBIAN = "sr", - SWEDISH = "sv", - TURKISH = "tr", - UKRANIAN = "uk", - CHINESE_SIMPLIFIED = "zh_Hans", - - OWO = "owo", - PIRATE = "pr", - BOTTOM = "bottom", - PIGLATIN = "piglatin", + ENGLISH = "en", + + ARABIC = "ar", + AZERBAIJANI = "az", + CZECH = "cs", + GERMAN = "de", + SPANISH = "es", + FINNISH = "fi", + FRENCH = "fr", + HINDI = "hi", + CROATIAN = "hr", + HUNGARIAN = "hu", + INDONESIAN = "id", + LITHUANIAN = "lt", + MACEDONIAN = "mk", + DUTCH = "nl", + POLISH = "pl", + PORTUGUESE_BRAZIL = "pt_BR", + ROMANIAN = "ro", + RUSSIAN = "ru", + SERBIAN = "sr", + SWEDISH = "sv", + TURKISH = "tr", + UKRANIAN = "uk", + CHINESE_SIMPLIFIED = "zh_Hans", + + OWO = "owo", + PIRATE = "pr", + BOTTOM = "bottom", + PIGLATIN = "piglatin", } export interface LanguageEntry { - display: string; - emoji: string; - i18n: string; - dayjs?: string; - rtl?: boolean; - alt?: boolean; + display: string; + emoji: string; + i18n: string; + dayjs?: string; + rtl?: boolean; + alt?: boolean; } export const Languages: { [key in Language]: LanguageEntry } = { - en: { - display: "English (Traditional)", - emoji: "🇬🇧", - i18n: "en", - dayjs: "en-gb", - }, - - ar: { display: "عربي", emoji: "🇸🇦", i18n: "ar", rtl: true }, - az: { display: "AzÉ™rbaycan dili", emoji: "🇦🇿", i18n: "az" }, - cs: { display: "ÄŒeÅ¡tina", emoji: "🇨🇿", i18n: "cs" }, - de: { display: "Deutsch", emoji: "🇩🇪", i18n: "de" }, - es: { display: "Español", emoji: "🇪🇸", i18n: "es" }, - fi: { display: "suomi", emoji: "🇫🇮", i18n: "fi" }, - fr: { display: "Français", emoji: "🇫🇷", i18n: "fr" }, - hi: { display: "हिनà¥à¤¦à¥€", emoji: "🇮🇳", i18n: "hi" }, - hr: { display: "Hrvatski", emoji: "ðŸ‡ðŸ‡·", i18n: "hr" }, - hu: { display: "magyar", emoji: "ðŸ‡ðŸ‡º", i18n: "hu" }, - id: { display: "bahasa Indonesia", emoji: "🇮🇩", i18n: "id" }, - lt: { display: "Lietuvių", emoji: "🇱🇹", i18n: "lt" }, - mk: { display: "МакедонÑки", emoji: "🇲🇰", i18n: "mk" }, - nl: { display: "Nederlands", emoji: "🇳🇱", i18n: "nl" }, - pl: { display: "Polski", emoji: "🇵🇱", i18n: "pl" }, - pt_BR: { - display: "Português (do Brasil)", - emoji: "🇧🇷", - i18n: "pt_BR", - dayjs: "pt-br", - }, - ro: { display: "Română", emoji: "🇷🇴", i18n: "ro" }, - ru: { display: "РуÑÑкий", emoji: "🇷🇺", i18n: "ru" }, - sr: { display: "СрпÑки", emoji: "🇷🇸", i18n: "sr" }, - sv: { display: "Svenska", emoji: "🇸🇪", i18n: "sv" }, - tr: { display: "Türkçe", emoji: "🇹🇷", i18n: "tr" }, - uk: { display: "УкраїнÑька", emoji: "🇺🇦", i18n: "uk" }, - zh_Hans: { - display: "䏿–‡ (简体)", - emoji: "🇨🇳", - i18n: "zh_Hans", - dayjs: "zh", - }, - - owo: { display: "OwO", emoji: "ðŸ±", i18n: "owo", dayjs: "en-gb", alt: true }, - pr: { display: "Pirate", emoji: "ðŸ´â€â˜ ï¸", i18n: "pr", dayjs: "en-gb", alt: true }, - bottom: { display: "Bottom", emoji: "🥺", i18n: "bottom", dayjs: "en-gb", alt: true }, - piglatin: { - display: "Pig Latin", - emoji: "ðŸ–", - i18n: "piglatin", - dayjs: "en-gb", - alt: true - }, + en: { + display: "English (Traditional)", + emoji: "🇬🇧", + i18n: "en", + dayjs: "en-gb", + }, + + ar: { display: "عربي", emoji: "🇸🇦", i18n: "ar", rtl: true }, + az: { display: "AzÉ™rbaycan dili", emoji: "🇦🇿", i18n: "az" }, + cs: { display: "ÄŒeÅ¡tina", emoji: "🇨🇿", i18n: "cs" }, + de: { display: "Deutsch", emoji: "🇩🇪", i18n: "de" }, + es: { display: "Español", emoji: "🇪🇸", i18n: "es" }, + fi: { display: "suomi", emoji: "🇫🇮", i18n: "fi" }, + fr: { display: "Français", emoji: "🇫🇷", i18n: "fr" }, + hi: { display: "हिनà¥à¤¦à¥€", emoji: "🇮🇳", i18n: "hi" }, + hr: { display: "Hrvatski", emoji: "ðŸ‡ðŸ‡·", i18n: "hr" }, + hu: { display: "magyar", emoji: "ðŸ‡ðŸ‡º", i18n: "hu" }, + id: { display: "bahasa Indonesia", emoji: "🇮🇩", i18n: "id" }, + lt: { display: "Lietuvių", emoji: "🇱🇹", i18n: "lt" }, + mk: { display: "МакедонÑки", emoji: "🇲🇰", i18n: "mk" }, + nl: { display: "Nederlands", emoji: "🇳🇱", i18n: "nl" }, + pl: { display: "Polski", emoji: "🇵🇱", i18n: "pl" }, + pt_BR: { + display: "Português (do Brasil)", + emoji: "🇧🇷", + i18n: "pt_BR", + dayjs: "pt-br", + }, + ro: { display: "Română", emoji: "🇷🇴", i18n: "ro" }, + ru: { display: "РуÑÑкий", emoji: "🇷🇺", i18n: "ru" }, + sr: { display: "СрпÑки", emoji: "🇷🇸", i18n: "sr" }, + sv: { display: "Svenska", emoji: "🇸🇪", i18n: "sv" }, + tr: { display: "Türkçe", emoji: "🇹🇷", i18n: "tr" }, + uk: { display: "УкраїнÑька", emoji: "🇺🇦", i18n: "uk" }, + zh_Hans: { + display: "䏿–‡ (简体)", + emoji: "🇨🇳", + i18n: "zh_Hans", + dayjs: "zh", + }, + + owo: { + display: "OwO", + emoji: "ðŸ±", + i18n: "owo", + dayjs: "en-gb", + alt: true, + }, + pr: { + display: "Pirate", + emoji: "ðŸ´â€â˜ ï¸", + i18n: "pr", + dayjs: "en-gb", + alt: true, + }, + bottom: { + display: "Bottom", + emoji: "🥺", + i18n: "bottom", + dayjs: "en-gb", + alt: true, + }, + piglatin: { + display: "Pig Latin", + emoji: "ðŸ–", + i18n: "piglatin", + dayjs: "en-gb", + alt: true, + }, }; interface Props { - children: JSX.Element | JSX.Element[]; - locale: Language; + children: JSX.Element | JSX.Element[]; + locale: Language; } function Locale({ children, locale }: Props) { - // TODO: create and use LanguageDefinition type here - const [defns, setDefinition] = useState<Record<string, unknown>>(definition); - const lang = Languages[locale]; - - // TODO: clean this up and use the built in Intl API - function transformLanguage(source: { [key: string]: any }) { - const obj = defaultsDeep(source, definition); - - const dayjs = obj.dayjs; - const defaults = dayjs.defaults; - - const twelvehour = defaults?.twelvehour === 'yes' || true; - const separator: '/' | '-' | '.' = defaults?.date_separator ?? '/'; - const date: 'traditional' | 'simplified' | 'ISO8601' = defaults?.date_format ?? 'traditional'; - - const DATE_FORMATS = { - traditional: `DD${separator}MM${separator}YYYY`, - simplified: `MM${separator}DD${separator}YYYY`, - ISO8601: 'YYYY-MM-DD' - } - - dayjs['sameElse'] = DATE_FORMATS[date]; - Object.keys(dayjs) - .filter(k => k !== 'defaults') - .forEach(k => dayjs[k] = dayjs[k].replace(/{{time}}/g, twelvehour ? 'LT' : 'HH:mm')); - - return obj; - } - - useEffect(() => { - if (locale === "en") { - const defn = transformLanguage(definition); - setDefinition(defn); - dayjs.locale("en"); - dayjs.updateLocale('en', { calendar: defn.dayjs }); - return; - } - - import(`../../external/lang/${lang.i18n}.json`).then( - async (lang_file) => { - const defn = transformLanguage(lang_file.default); - const target = lang.dayjs ?? lang.i18n; - const dayjs_locale = await import(`../../node_modules/dayjs/esm/locale/${target}.js`); - - if (defn.dayjs) { - dayjs.updateLocale(target, { calendar: defn.dayjs }); - } - - dayjs.locale(dayjs_locale.default); - setDefinition(defn); - } - ); - }, [locale, lang]); - - useEffect(() => { - document.body.style.direction = lang.rtl ? "rtl" : ""; - }, [lang.rtl]); - - return <IntlProvider definition={defns}>{children}</IntlProvider>; + // TODO: create and use LanguageDefinition type here + const [defns, setDefinition] = + useState<Record<string, unknown>>(definition); + const lang = Languages[locale]; + + // TODO: clean this up and use the built in Intl API + function transformLanguage(source: { [key: string]: any }) { + const obj = defaultsDeep(source, definition); + + const dayjs = obj.dayjs; + const defaults = dayjs.defaults; + + const twelvehour = defaults?.twelvehour === "yes" || true; + const separator: "/" | "-" | "." = defaults?.date_separator ?? "/"; + const date: "traditional" | "simplified" | "ISO8601" = + defaults?.date_format ?? "traditional"; + + const DATE_FORMATS = { + traditional: `DD${separator}MM${separator}YYYY`, + simplified: `MM${separator}DD${separator}YYYY`, + ISO8601: "YYYY-MM-DD", + }; + + dayjs["sameElse"] = DATE_FORMATS[date]; + Object.keys(dayjs) + .filter((k) => k !== "defaults") + .forEach( + (k) => + (dayjs[k] = dayjs[k].replace( + /{{time}}/g, + twelvehour ? "LT" : "HH:mm", + )), + ); + + return obj; + } + + useEffect(() => { + if (locale === "en") { + const defn = transformLanguage(definition); + setDefinition(defn); + dayjs.locale("en"); + dayjs.updateLocale("en", { calendar: defn.dayjs }); + return; + } + + import(`../../external/lang/${lang.i18n}.json`).then( + async (lang_file) => { + const defn = transformLanguage(lang_file.default); + const target = lang.dayjs ?? lang.i18n; + const dayjs_locale = await import( + `../../node_modules/dayjs/esm/locale/${target}.js` + ); + + if (defn.dayjs) { + dayjs.updateLocale(target, { calendar: defn.dayjs }); + } + + dayjs.locale(dayjs_locale.default); + setDefinition(defn); + }, + ); + }, [locale, lang]); + + useEffect(() => { + document.body.style.direction = lang.rtl ? "rtl" : ""; + }, [lang.rtl]); + + return <IntlProvider definition={defns}>{children}</IntlProvider>; } export default connectState<Omit<Props, "locale">>( - Locale, - (state) => { - return { - locale: state.locale, - }; - }, - true + Locale, + (state) => { + return { + locale: state.locale, + }; + }, + true, ); diff --git a/src/context/Settings.tsx b/src/context/Settings.tsx index 3653b93e772ada8c62710b82ada757949047e955..587f2a94f2c1b80d0e74ec2069b5ca11dbda81c3 100644 --- a/src/context/Settings.tsx +++ b/src/context/Settings.tsx @@ -6,44 +6,56 @@ // if it does cause problems though. // // This now also supports Audio stuff. - -import { DEFAULT_SOUNDS, Settings, SoundOptions } from "../redux/reducers/settings"; -import { playSound, Sounds } from "../assets/sounds/Audio"; -import { connectState } from "../redux/connector"; import defaultsDeep from "lodash.defaultsdeep"; -import { Children } from "../types/Preact"; + import { createContext } from "preact"; import { useMemo } from "preact/hooks"; +import { connectState } from "../redux/connector"; +import { + DEFAULT_SOUNDS, + Settings, + SoundOptions, +} from "../redux/reducers/settings"; + +import { playSound, Sounds } from "../assets/sounds/Audio"; +import { Children } from "../types/Preact"; + export const SettingsContext = createContext<Settings>({}); -export const SoundContext = createContext<((sound: Sounds) => void)>(null!); +export const SoundContext = createContext<(sound: Sounds) => void>(null!); interface Props { - children?: Children, - settings: Settings + children?: Children; + settings: Settings; } -function Settings({ settings, children }: Props) { - const play = useMemo(() => { - const enabled: SoundOptions = defaultsDeep(settings.notification?.sounds ?? {}, DEFAULT_SOUNDS); - return (sound: Sounds) => { - if (enabled[sound]) { - playSound(sound); - } - }; - }, [ settings.notification ]); - - return ( - <SettingsContext.Provider value={settings}> - <SoundContext.Provider value={play}> - { children } - </SoundContext.Provider> - </SettingsContext.Provider> - ) +function SettingsProvider({ settings, children }: Props) { + const play = useMemo(() => { + const enabled: SoundOptions = defaultsDeep( + settings.notification?.sounds ?? {}, + DEFAULT_SOUNDS, + ); + return (sound: Sounds) => { + if (enabled[sound]) { + playSound(sound); + } + }; + }, [settings.notification]); + + return ( + <SettingsContext.Provider value={settings}> + <SoundContext.Provider value={play}> + {children} + </SoundContext.Provider> + </SettingsContext.Provider> + ); } -export default connectState<Omit<Props, 'settings'>>(Settings, state => { - return { - settings: state.settings - } -}); +export default connectState<Omit<Props, "settings">>( + SettingsProvider, + (state) => { + return { + settings: state.settings, + }; + }, +); diff --git a/src/context/Theme.tsx b/src/context/Theme.tsx index 8a9fc54e11c32954e398f3ab4826726eb64376ef..af0d218abfa1ad41acd84e3923add1647afbfec5 100644 --- a/src/context/Theme.tsx +++ b/src/context/Theme.tsx @@ -1,335 +1,360 @@ -import { isTouchscreenDevice } from "../lib/isTouchscreenDevice"; +import { Helmet } from "react-helmet"; import { createGlobalStyle } from "styled-components"; + +import { createContext } from "preact"; +import { useEffect } from "preact/hooks"; + +import { isTouchscreenDevice } from "../lib/isTouchscreenDevice"; + import { connectState } from "../redux/connector"; + import { Children } from "../types/Preact"; -import { useEffect } from "preact/hooks"; -import { createContext } from "preact"; -import { Helmet } from "react-helmet"; export type Variables = - | "accent" - | "background" - | "foreground" - | "block" - | "message-box" - | "mention" - | "success" - | "warning" - | "error" - | "hover" - | "scrollbar-thumb" - | "scrollbar-track" - | "primary-background" - | "primary-header" - | "secondary-background" - | "secondary-foreground" - | "secondary-header" - | "tertiary-background" - | "tertiary-foreground" - | "status-online" - | "status-away" - | "status-busy" - | "status-streaming" - | "status-invisible" + | "accent" + | "background" + | "foreground" + | "block" + | "message-box" + | "mention" + | "success" + | "warning" + | "error" + | "hover" + | "scrollbar-thumb" + | "scrollbar-track" + | "primary-background" + | "primary-header" + | "secondary-background" + | "secondary-foreground" + | "secondary-header" + | "tertiary-background" + | "tertiary-foreground" + | "status-online" + | "status-away" + | "status-busy" + | "status-streaming" + | "status-invisible"; // While this isn't used, it'd be good to keep this up to date as a reference or for future use export type HiddenVariables = - | "font" - | "ligatures" - | "app-height" - | "sidebar-active" - | "monospace-font" + | "font" + | "ligatures" + | "app-height" + | "sidebar-active" + | "monospace-font"; -export type Fonts = 'Open Sans' | 'Inter' | 'Atkinson Hyperlegible' | 'Roboto' | 'Noto Sans' | 'Lato' | 'Bree Serif' | 'Montserrat' | 'Poppins' | 'Raleway' | 'Ubuntu' | 'Comic Neue'; -export type MonoscapeFonts = 'Fira Code' | 'Roboto Mono' | 'Source Code Pro' | 'Space Mono' | 'Ubuntu Mono'; +export type Fonts = + | "Open Sans" + | "Inter" + | "Atkinson Hyperlegible" + | "Roboto" + | "Noto Sans" + | "Lato" + | "Bree Serif" + | "Montserrat" + | "Poppins" + | "Raleway" + | "Ubuntu" + | "Comic Neue"; +export type MonoscapeFonts = + | "Fira Code" + | "Roboto Mono" + | "Source Code Pro" + | "Space Mono" + | "Ubuntu Mono"; export type Theme = { - [variable in Variables]: string; + [variable in Variables]: string; } & { - light?: boolean; - font?: Fonts; - css?: string; - monoscapeFont?: MonoscapeFonts; + light?: boolean; + font?: Fonts; + css?: string; + monoscapeFont?: MonoscapeFonts; }; export interface ThemeOptions { - preset?: string; - ligatures?: boolean; - custom?: Partial<Theme>; + preset?: string; + ligatures?: boolean; + custom?: Partial<Theme>; } // import aaa from "@fontsource/open-sans/300.css?raw"; // console.info(aaa); -export const FONTS: Record<Fonts, { name: string, load: () => void }> = { - "Open Sans": { - name: "Open Sans", - load: async () => { - await import("@fontsource/open-sans/300.css"); - await import("@fontsource/open-sans/400.css"); - await import("@fontsource/open-sans/600.css"); - await import("@fontsource/open-sans/700.css"); - await import("@fontsource/open-sans/400-italic.css"); - } - }, - Inter: { - name: "Inter", - load: async () => { - await import("@fontsource/inter/300.css"); - await import("@fontsource/inter/400.css"); - await import("@fontsource/inter/600.css"); - await import("@fontsource/inter/700.css"); - } - }, - "Atkinson Hyperlegible": { - name: "Atkinson Hyperlegible", - load: async () => { - await import("@fontsource/atkinson-hyperlegible/400.css"); - await import("@fontsource/atkinson-hyperlegible/700.css"); - await import("@fontsource/atkinson-hyperlegible/400-italic.css"); - } - }, - "Roboto": { - name: "Roboto", - load: async () => { - await import("@fontsource/roboto/400.css"); - await import("@fontsource/roboto/700.css"); - await import("@fontsource/roboto/400-italic.css"); - } - }, - "Noto Sans": { - name: "Noto Sans", - load: async () => { - await import("@fontsource/noto-sans/400.css"); - await import("@fontsource/noto-sans/700.css"); - await import("@fontsource/noto-sans/400-italic.css"); - } - }, - "Bree Serif": { - name: "Bree Serif", - load: () => import("@fontsource/bree-serif/400.css") - }, - "Lato": { - name: "Lato", - load: async () => { - await import("@fontsource/lato/300.css"); - await import("@fontsource/lato/400.css"); - await import("@fontsource/lato/700.css"); - await import("@fontsource/lato/400-italic.css"); - } - }, - "Montserrat": { - name: "Montserrat", - load: async () => { - await import("@fontsource/montserrat/300.css"); - await import("@fontsource/montserrat/400.css"); - await import("@fontsource/montserrat/600.css"); - await import("@fontsource/montserrat/700.css"); - await import("@fontsource/montserrat/400-italic.css"); - } - }, - "Poppins": { - name: "Poppins", - load: async () => { - await import("@fontsource/poppins/300.css"); - await import("@fontsource/poppins/400.css"); - await import("@fontsource/poppins/600.css"); - await import("@fontsource/poppins/700.css"); - await import("@fontsource/poppins/400-italic.css"); - } - }, - "Raleway": { - name: "Raleway", - load: async () => { - await import("@fontsource/raleway/300.css"); - await import("@fontsource/raleway/400.css"); - await import("@fontsource/raleway/600.css"); - await import("@fontsource/raleway/700.css"); - await import("@fontsource/raleway/400-italic.css"); - } - }, - "Ubuntu": { - name: "Ubuntu", - load: async () => { - await import("@fontsource/ubuntu/300.css"); - await import("@fontsource/ubuntu/400.css"); - await import("@fontsource/ubuntu/500.css"); - await import("@fontsource/ubuntu/700.css"); - await import("@fontsource/ubuntu/400-italic.css"); - } - }, - "Comic Neue": { - name: "Comic Neue", - load: async () => { - await import("@fontsource/comic-neue/300.css"); - await import("@fontsource/comic-neue/400.css"); - await import("@fontsource/comic-neue/700.css"); - await import("@fontsource/comic-neue/400-italic.css"); - } - } +export const FONTS: Record<Fonts, { name: string; load: () => void }> = { + "Open Sans": { + name: "Open Sans", + load: async () => { + await import("@fontsource/open-sans/300.css"); + await import("@fontsource/open-sans/400.css"); + await import("@fontsource/open-sans/600.css"); + await import("@fontsource/open-sans/700.css"); + await import("@fontsource/open-sans/400-italic.css"); + }, + }, + Inter: { + name: "Inter", + load: async () => { + await import("@fontsource/inter/300.css"); + await import("@fontsource/inter/400.css"); + await import("@fontsource/inter/600.css"); + await import("@fontsource/inter/700.css"); + }, + }, + "Atkinson Hyperlegible": { + name: "Atkinson Hyperlegible", + load: async () => { + await import("@fontsource/atkinson-hyperlegible/400.css"); + await import("@fontsource/atkinson-hyperlegible/700.css"); + await import("@fontsource/atkinson-hyperlegible/400-italic.css"); + }, + }, + Roboto: { + name: "Roboto", + load: async () => { + await import("@fontsource/roboto/400.css"); + await import("@fontsource/roboto/700.css"); + await import("@fontsource/roboto/400-italic.css"); + }, + }, + "Noto Sans": { + name: "Noto Sans", + load: async () => { + await import("@fontsource/noto-sans/400.css"); + await import("@fontsource/noto-sans/700.css"); + await import("@fontsource/noto-sans/400-italic.css"); + }, + }, + "Bree Serif": { + name: "Bree Serif", + load: () => import("@fontsource/bree-serif/400.css"), + }, + Lato: { + name: "Lato", + load: async () => { + await import("@fontsource/lato/300.css"); + await import("@fontsource/lato/400.css"); + await import("@fontsource/lato/700.css"); + await import("@fontsource/lato/400-italic.css"); + }, + }, + Montserrat: { + name: "Montserrat", + load: async () => { + await import("@fontsource/montserrat/300.css"); + await import("@fontsource/montserrat/400.css"); + await import("@fontsource/montserrat/600.css"); + await import("@fontsource/montserrat/700.css"); + await import("@fontsource/montserrat/400-italic.css"); + }, + }, + Poppins: { + name: "Poppins", + load: async () => { + await import("@fontsource/poppins/300.css"); + await import("@fontsource/poppins/400.css"); + await import("@fontsource/poppins/600.css"); + await import("@fontsource/poppins/700.css"); + await import("@fontsource/poppins/400-italic.css"); + }, + }, + Raleway: { + name: "Raleway", + load: async () => { + await import("@fontsource/raleway/300.css"); + await import("@fontsource/raleway/400.css"); + await import("@fontsource/raleway/600.css"); + await import("@fontsource/raleway/700.css"); + await import("@fontsource/raleway/400-italic.css"); + }, + }, + Ubuntu: { + name: "Ubuntu", + load: async () => { + await import("@fontsource/ubuntu/300.css"); + await import("@fontsource/ubuntu/400.css"); + await import("@fontsource/ubuntu/500.css"); + await import("@fontsource/ubuntu/700.css"); + await import("@fontsource/ubuntu/400-italic.css"); + }, + }, + "Comic Neue": { + name: "Comic Neue", + load: async () => { + await import("@fontsource/comic-neue/300.css"); + await import("@fontsource/comic-neue/400.css"); + await import("@fontsource/comic-neue/700.css"); + await import("@fontsource/comic-neue/400-italic.css"); + }, + }, }; -export const MONOSCAPE_FONTS: Record<MonoscapeFonts, { name: string, load: () => void }> = { - "Fira Code": { - name: "Fira Code", - load: () => import("@fontsource/fira-code/400.css") - }, - "Roboto Mono": { - name: "Roboto Mono", - load: () => import("@fontsource/roboto-mono/400.css") - }, - "Source Code Pro": { - name: "Source Code Pro", - load: () => import("@fontsource/source-code-pro/400.css") - }, - "Space Mono": { - name: "Space Mono", - load: () => import("@fontsource/space-mono/400.css") - }, - "Ubuntu Mono": { - name: "Ubuntu Mono", - load: () => import("@fontsource/ubuntu-mono/400.css") - } +export const MONOSCAPE_FONTS: Record< + MonoscapeFonts, + { name: string; load: () => void } +> = { + "Fira Code": { + name: "Fira Code", + load: () => import("@fontsource/fira-code/400.css"), + }, + "Roboto Mono": { + name: "Roboto Mono", + load: () => import("@fontsource/roboto-mono/400.css"), + }, + "Source Code Pro": { + name: "Source Code Pro", + load: () => import("@fontsource/source-code-pro/400.css"), + }, + "Space Mono": { + name: "Space Mono", + load: () => import("@fontsource/space-mono/400.css"), + }, + "Ubuntu Mono": { + name: "Ubuntu Mono", + load: () => import("@fontsource/ubuntu-mono/400.css"), + }, }; export const FONT_KEYS = Object.keys(FONTS).sort(); export const MONOSCAPE_FONT_KEYS = Object.keys(MONOSCAPE_FONTS).sort(); -export const DEFAULT_FONT = 'Open Sans'; -export const DEFAULT_MONO_FONT = 'Fira Code'; +export const DEFAULT_FONT = "Open Sans"; +export const DEFAULT_MONO_FONT = "Fira Code"; // Generated from https://gitlab.insrt.uk/revolt/community/themes export const PRESETS: Record<string, Theme> = { - light: { - light: true, - accent: "#FD6671", - background: "#F6F6F6", - foreground: "#101010", - block: "#414141", - "message-box": "#F1F1F1", - mention: "rgba(251, 255, 0, 0.40)", - success: "#65E572", - warning: "#FAA352", - error: "#F06464", - hover: "rgba(0, 0, 0, 0.2)", - "scrollbar-thumb": "#CA525A", - "scrollbar-track": "transparent", - "primary-background": "#FFFFFF", - "primary-header": "#F1F1F1", - "secondary-background": "#F1F1F1", - "secondary-foreground": "#888888", - "secondary-header": "#F1F1F1", - "tertiary-background": "#4D4D4D", - "tertiary-foreground": "#646464", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - }, - dark: { - light: false, - accent: "#FD6671", - background: "#191919", - foreground: "#F6F6F6", - block: "#2D2D2D", - "message-box": "#363636", - mention: "rgba(251, 255, 0, 0.06)", - success: "#65E572", - warning: "#FAA352", - error: "#F06464", - hover: "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#CA525A", - "scrollbar-track": "transparent", - "primary-background": "#242424", - "primary-header": "#363636", - "secondary-background": "#1E1E1E", - "secondary-foreground": "#C8C8C8", - "secondary-header": "#2D2D2D", - "tertiary-background": "#4D4D4D", - "tertiary-foreground": "#848484", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - }, + light: { + light: true, + accent: "#FD6671", + background: "#F6F6F6", + foreground: "#101010", + block: "#414141", + "message-box": "#F1F1F1", + mention: "rgba(251, 255, 0, 0.40)", + success: "#65E572", + warning: "#FAA352", + error: "#F06464", + hover: "rgba(0, 0, 0, 0.2)", + "scrollbar-thumb": "#CA525A", + "scrollbar-track": "transparent", + "primary-background": "#FFFFFF", + "primary-header": "#F1F1F1", + "secondary-background": "#F1F1F1", + "secondary-foreground": "#888888", + "secondary-header": "#F1F1F1", + "tertiary-background": "#4D4D4D", + "tertiary-foreground": "#646464", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5", + }, + dark: { + light: false, + accent: "#FD6671", + background: "#191919", + foreground: "#F6F6F6", + block: "#2D2D2D", + "message-box": "#363636", + mention: "rgba(251, 255, 0, 0.06)", + success: "#65E572", + warning: "#FAA352", + error: "#F06464", + hover: "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#CA525A", + "scrollbar-track": "transparent", + "primary-background": "#242424", + "primary-header": "#363636", + "secondary-background": "#1E1E1E", + "secondary-foreground": "#C8C8C8", + "secondary-header": "#2D2D2D", + "tertiary-background": "#4D4D4D", + "tertiary-foreground": "#848484", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5", + }, }; const keys = Object.keys(PRESETS.dark); const GlobalTheme = createGlobalStyle<{ theme: Theme }>` :root { ${(props) => - (Object.keys(props.theme) as Variables[]).map((key) => { - if (!keys.includes(key)) return; - return `--${key}: ${props.theme[key]};`; - })} + (Object.keys(props.theme) as Variables[]).map((key) => { + if (!keys.includes(key)) return; + return `--${key}: ${props.theme[key]};`; + })} } `; // Load the default default them and apply extras later -export const ThemeContext = createContext<Theme>(PRESETS['dark']); +export const ThemeContext = createContext<Theme>(PRESETS["dark"]); interface Props { - children: Children; - options?: ThemeOptions; + children: Children; + options?: ThemeOptions; } function Theme({ children, options }: Props) { - const theme: Theme = { - ...PRESETS["dark"], - ...PRESETS[options?.preset ?? ''], - ...options?.custom - }; + const theme: Theme = { + ...PRESETS["dark"], + ...PRESETS[options?.preset ?? ""], + ...options?.custom, + }; - const root = document.documentElement.style; - useEffect(() => { - const font = theme.font ?? DEFAULT_FONT; - root.setProperty('--font', `"${font}"`); - FONTS[font].load(); - }, [ theme.font ]); + const root = document.documentElement.style; + useEffect(() => { + const font = theme.font ?? DEFAULT_FONT; + root.setProperty("--font", `"${font}"`); + FONTS[font].load(); + }, [theme.font]); - useEffect(() => { - const font = theme.monoscapeFont ?? DEFAULT_MONO_FONT; - root.setProperty('--monoscape-font', `"${font}"`); - MONOSCAPE_FONTS[font].load(); - }, [ theme.monoscapeFont ]); + useEffect(() => { + const font = theme.monoscapeFont ?? DEFAULT_MONO_FONT; + root.setProperty("--monoscape-font", `"${font}"`); + MONOSCAPE_FONTS[font].load(); + }, [theme.monoscapeFont]); - useEffect(() => { - root.setProperty('--ligatures', options?.ligatures ? 'normal' : 'none'); - }, [ options?.ligatures ]); + useEffect(() => { + root.setProperty("--ligatures", options?.ligatures ? "normal" : "none"); + }, [options?.ligatures]); - useEffect(() => { - const resize = () => root.setProperty('--app-height', `${window.innerHeight}px`); - resize(); + useEffect(() => { + const resize = () => + root.setProperty("--app-height", `${window.innerHeight}px`); + resize(); - window.addEventListener('resize', resize); - return () => window.removeEventListener('resize', resize); - }, []); + window.addEventListener("resize", resize); + return () => window.removeEventListener("resize", resize); + }, []); - return ( - <ThemeContext.Provider value={theme}> - <Helmet> - <meta - name="theme-color" - content={ - isTouchscreenDevice - ? theme["primary-header"] - : theme["background"] - } - /> - </Helmet> - <GlobalTheme theme={theme} /> - {theme.css && ( - <style dangerouslySetInnerHTML={{ __html: theme.css }} /> - )} - { children } - </ThemeContext.Provider> - ); + return ( + <ThemeContext.Provider value={theme}> + <Helmet> + <meta + name="theme-color" + content={ + isTouchscreenDevice + ? theme["primary-header"] + : theme["background"] + } + /> + </Helmet> + <GlobalTheme theme={theme} /> + {theme.css && ( + <style dangerouslySetInnerHTML={{ __html: theme.css }} /> + )} + {children} + </ThemeContext.Provider> + ); } -export default connectState<{ children: Children }>(Theme, state => { - return { - options: state.settings.theme - }; +export default connectState<{ children: Children }>(Theme, (state) => { + return { + options: state.settings.theme, + }; }); diff --git a/src/context/Voice.tsx b/src/context/Voice.tsx index 6190c363d31c14909d7032f418fe27ba23b8e05b..b20f5824c790fa84bd82674551fca2476844112c 100644 --- a/src/context/Voice.tsx +++ b/src/context/Voice.tsx @@ -1,36 +1,38 @@ import { createContext } from "preact"; -import { Children } from "../types/Preact"; -import { useForceUpdate } from "./revoltjs/hooks"; -import { AppContext } from "./revoltjs/RevoltClient"; -import type VoiceClient from "../lib/vortex/VoiceClient"; -import type { ProduceType, VoiceUser } from "../lib/vortex/Types"; import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks"; + +import type { ProduceType, VoiceUser } from "../lib/vortex/Types"; +import type VoiceClient from "../lib/vortex/VoiceClient"; + +import { Children } from "../types/Preact"; import { SoundContext } from "./Settings"; +import { AppContext } from "./revoltjs/RevoltClient"; +import { useForceUpdate } from "./revoltjs/hooks"; export enum VoiceStatus { - LOADING = 0, - UNAVAILABLE, - ERRORED, - READY = 3, - CONNECTING = 4, - AUTHENTICATING, - RTC_CONNECTING, - CONNECTED - // RECONNECTING + LOADING = 0, + UNAVAILABLE, + ERRORED, + READY = 3, + CONNECTING = 4, + AUTHENTICATING, + RTC_CONNECTING, + CONNECTED, + // RECONNECTING } export interface VoiceOperations { - connect: (channelId: string) => Promise<void>; - disconnect: () => void; - isProducing: (type: ProduceType) => boolean; - startProducing: (type: ProduceType) => Promise<void>; - stopProducing: (type: ProduceType) => Promise<void> | undefined; + connect: (channelId: string) => Promise<void>; + disconnect: () => void; + isProducing: (type: ProduceType) => boolean; + startProducing: (type: ProduceType) => Promise<void>; + stopProducing: (type: ProduceType) => Promise<void> | undefined; } export interface VoiceState { - roomId?: string; - status: VoiceStatus; - participants?: Readonly<Map<string, VoiceUser>>; + roomId?: string; + status: VoiceStatus; + participants?: Readonly<Map<string, VoiceUser>>; } // They should be present from first render. - insert's words @@ -38,166 +40,168 @@ export const VoiceContext = createContext<VoiceState>(null!); export const VoiceOperationsContext = createContext<VoiceOperations>(null!); type Props = { - children: Children; + children: Children; }; export default function Voice({ children }: Props) { - const revoltClient = useContext(AppContext); - const [client, setClient] = useState<VoiceClient | undefined>(undefined); - const [state, setState] = useState<VoiceState>({ - status: VoiceStatus.LOADING, - participants: new Map() - }); - - function setStatus(status: VoiceStatus, roomId?: string) { - setState({ - status, - roomId: roomId ?? client?.roomId, - participants: client?.participants ?? new Map(), - }); - } - - useEffect(() => { - import('../lib/vortex/VoiceClient') - .then(({ default: VoiceClient }) => { - const client = new VoiceClient(); - setClient(client); - - if (!client?.supported()) { - setStatus(VoiceStatus.UNAVAILABLE); - } else { - setStatus(VoiceStatus.READY); - } - }) - .catch(err => { - console.error('Failed to load voice library!', err); - setStatus(VoiceStatus.UNAVAILABLE); - }) - }, []); - - const isConnecting = useRef(false); - const operations: VoiceOperations = useMemo(() => { - return { - connect: async channelId => { - if (!client?.supported()) - throw new Error("RTC is unavailable"); - - isConnecting.current = true; - setStatus(VoiceStatus.CONNECTING, channelId); - - try { - const call = await revoltClient.channels.joinCall( - channelId - ); - - if (!isConnecting.current) { - setStatus(VoiceStatus.READY); - return; - } - - // ! FIXME: use configuration to check if voso is enabled - // await client.connect("wss://voso.revolt.chat/ws"); - await client.connect("wss://voso.revolt.chat/ws", channelId); - - setStatus(VoiceStatus.AUTHENTICATING); - - await client.authenticate(call.token); - setStatus(VoiceStatus.RTC_CONNECTING); - - await client.initializeTransports(); - } catch (error) { - console.error(error); - setStatus(VoiceStatus.READY); - return; - } - - setStatus(VoiceStatus.CONNECTED); - isConnecting.current = false; - }, - disconnect: () => { - if (!client?.supported()) - throw new Error("RTC is unavailable"); - - // if (status <= VoiceStatus.READY) return; - // this will not update in this context - - isConnecting.current = false; - client.disconnect(); - setStatus(VoiceStatus.READY); - }, - isProducing: (type: ProduceType) => { - switch (type) { - case "audio": - return client?.audioProducer !== undefined; - } - }, - startProducing: async (type: ProduceType) => { - switch (type) { - case "audio": { - if (client?.audioProducer !== undefined) return console.log('No audio producer.'); // ! FIXME: let the user know - if (navigator.mediaDevices === undefined) return console.log('No media devices.'); // ! FIXME: let the user know - const mediaStream = await navigator.mediaDevices.getUserMedia( - { - audio: true - } - ); - - await client?.startProduce( - mediaStream.getAudioTracks()[0], - "audio" - ); - return; - } - } - }, - stopProducing: (type: ProduceType) => { - return client?.stopProduce(type); - } - } - }, [ client ]); - - const { forceUpdate } = useForceUpdate(); - const playSound = useContext(SoundContext); - - useEffect(() => { - if (!client?.supported()) return; - - // ! FIXME: message for fatal: - // ! get rid of these force updates - // ! handle it through state or smth - - client.on("startProduce", forceUpdate); - client.on("stopProduce", forceUpdate); - - client.on("userJoined", () => { - playSound('call_join'); - forceUpdate(); - }); - client.on("userLeft", () => { - playSound('call_leave'); - forceUpdate(); - }); - client.on("userStartProduce", forceUpdate); - client.on("userStopProduce", forceUpdate); - client.on("close", forceUpdate); - - return () => { - client.removeListener("startProduce", forceUpdate); - client.removeListener("stopProduce", forceUpdate); - - client.removeListener("userJoined", forceUpdate); - client.removeListener("userLeft", forceUpdate); - client.removeListener("userStartProduce", forceUpdate); - client.removeListener("userStopProduce", forceUpdate); - client.removeListener("close", forceUpdate); - }; - }, [ client, state ]); - - return ( - <VoiceContext.Provider value={state}> - <VoiceOperationsContext.Provider value={operations}> - { children } - </VoiceOperationsContext.Provider> - </VoiceContext.Provider> - ); + const revoltClient = useContext(AppContext); + const [client, setClient] = useState<VoiceClient | undefined>(undefined); + const [state, setState] = useState<VoiceState>({ + status: VoiceStatus.LOADING, + participants: new Map(), + }); + + function setStatus(status: VoiceStatus, roomId?: string) { + setState({ + status, + roomId: roomId ?? client?.roomId, + participants: client?.participants ?? new Map(), + }); + } + + useEffect(() => { + import("../lib/vortex/VoiceClient") + .then(({ default: VoiceClient }) => { + const client = new VoiceClient(); + setClient(client); + + if (!client?.supported()) { + setStatus(VoiceStatus.UNAVAILABLE); + } else { + setStatus(VoiceStatus.READY); + } + }) + .catch((err) => { + console.error("Failed to load voice library!", err); + setStatus(VoiceStatus.UNAVAILABLE); + }); + }, []); + + const isConnecting = useRef(false); + const operations: VoiceOperations = useMemo(() => { + return { + connect: async (channelId) => { + if (!client?.supported()) throw new Error("RTC is unavailable"); + + isConnecting.current = true; + setStatus(VoiceStatus.CONNECTING, channelId); + + try { + const call = await revoltClient.channels.joinCall( + channelId, + ); + + if (!isConnecting.current) { + setStatus(VoiceStatus.READY); + return; + } + + // ! FIXME: use configuration to check if voso is enabled + // await client.connect("wss://voso.revolt.chat/ws"); + await client.connect( + "wss://voso.revolt.chat/ws", + channelId, + ); + + setStatus(VoiceStatus.AUTHENTICATING); + + await client.authenticate(call.token); + setStatus(VoiceStatus.RTC_CONNECTING); + + await client.initializeTransports(); + } catch (error) { + console.error(error); + setStatus(VoiceStatus.READY); + return; + } + + setStatus(VoiceStatus.CONNECTED); + isConnecting.current = false; + }, + disconnect: () => { + if (!client?.supported()) throw new Error("RTC is unavailable"); + + // if (status <= VoiceStatus.READY) return; + // this will not update in this context + + isConnecting.current = false; + client.disconnect(); + setStatus(VoiceStatus.READY); + }, + isProducing: (type: ProduceType) => { + switch (type) { + case "audio": + return client?.audioProducer !== undefined; + } + }, + startProducing: async (type: ProduceType) => { + switch (type) { + case "audio": { + if (client?.audioProducer !== undefined) + return console.log("No audio producer."); // ! FIXME: let the user know + if (navigator.mediaDevices === undefined) + return console.log("No media devices."); // ! FIXME: let the user know + const mediaStream = + await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + + await client?.startProduce( + mediaStream.getAudioTracks()[0], + "audio", + ); + return; + } + } + }, + stopProducing: (type: ProduceType) => { + return client?.stopProduce(type); + }, + }; + }, [client]); + + const { forceUpdate } = useForceUpdate(); + const playSound = useContext(SoundContext); + + useEffect(() => { + if (!client?.supported()) return; + + // ! FIXME: message for fatal: + // ! get rid of these force updates + // ! handle it through state or smth + + client.on("startProduce", forceUpdate); + client.on("stopProduce", forceUpdate); + + client.on("userJoined", () => { + playSound("call_join"); + forceUpdate(); + }); + client.on("userLeft", () => { + playSound("call_leave"); + forceUpdate(); + }); + client.on("userStartProduce", forceUpdate); + client.on("userStopProduce", forceUpdate); + client.on("close", forceUpdate); + + return () => { + client.removeListener("startProduce", forceUpdate); + client.removeListener("stopProduce", forceUpdate); + + client.removeListener("userJoined", forceUpdate); + client.removeListener("userLeft", forceUpdate); + client.removeListener("userStartProduce", forceUpdate); + client.removeListener("userStopProduce", forceUpdate); + client.removeListener("close", forceUpdate); + }; + }, [client, state]); + + return ( + <VoiceContext.Provider value={state}> + <VoiceOperationsContext.Provider value={operations}> + {children} + </VoiceOperationsContext.Provider> + </VoiceContext.Provider> + ); } diff --git a/src/context/index.tsx b/src/context/index.tsx index 4cbbb76bd0f16049a949ef9df491554fa10163f6..f8f94723f59b4317c76bf5caf73511ef7d7480c9 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -1,32 +1,31 @@ -import State from "../redux/State"; -import { Children } from "../types/Preact"; import { BrowserRouter as Router } from "react-router-dom"; -import Intermediate from './intermediate/Intermediate'; -import Client from './revoltjs/RevoltClient'; -import Settings from "./Settings"; +import State from "../redux/State"; + +import { Children } from "../types/Preact"; import Locale from "./Locale"; -import Voice from "./Voice"; +import Settings from "./Settings"; import Theme from "./Theme"; +import Voice from "./Voice"; +import Intermediate from "./intermediate/Intermediate"; +import Client from "./revoltjs/RevoltClient"; export default function Context({ children }: { children: Children }) { - return ( - <Router> - <State> - <Theme> - <Settings> - <Locale> - <Intermediate> - <Client> - <Voice> - {children} - </Voice> - </Client> - </Intermediate> - </Locale> - </Settings> - </Theme> - </State> - </Router> - ); + return ( + <Router> + <State> + <Theme> + <Settings> + <Locale> + <Intermediate> + <Client> + <Voice>{children}</Voice> + </Client> + </Intermediate> + </Locale> + </Settings> + </Theme> + </State> + </Router> + ); } diff --git a/src/context/intermediate/Intermediate.tsx b/src/context/intermediate/Intermediate.tsx index 0b2929aed192b95d00fa620dad7fd4fc16db196e..8bbdfb1122dd04d891982ccf31c1031a3098777a 100644 --- a/src/context/intermediate/Intermediate.tsx +++ b/src/context/intermediate/Intermediate.tsx @@ -1,141 +1,179 @@ -import { Attachment, Channels, EmbedImage, Servers, Users } from "revolt.js/dist/api/objects"; +import { Prompt } from "react-router"; +import { useHistory } from "react-router-dom"; +import { + Attachment, + Channels, + EmbedImage, + Servers, + Users, +} from "revolt.js/dist/api/objects"; + +import { createContext } from "preact"; import { useContext, useEffect, useMemo, useState } from "preact/hooks"; + import { internalSubscribe } from "../../lib/eventEmitter"; + import { Action } from "../../components/ui/Modal"; -import { useHistory } from "react-router-dom"; + import { Children } from "../../types/Preact"; -import { createContext } from "preact"; -import { Prompt } from "react-router"; -import Modals from './Modals'; +import Modals from "./Modals"; export type Screen = -| { id: "none" } - -// Modals -| { id: "signed_out" } -| { id: "error"; error: string } -| { id: "clipboard"; text: string } -| { id: "_prompt"; question: Children; content?: Children; actions: Action[] } -| ({ id: "special_prompt" } & ( - { type: "leave_group", target: Channels.GroupChannel } | - { type: "close_dm", target: Channels.DirectMessageChannel } | - { type: "leave_server", target: Servers.Server } | - { type: "delete_server", target: Servers.Server } | - { type: "delete_channel", target: Channels.TextChannel } | - { type: "delete_message", target: Channels.Message } | - { type: "create_invite", target: Channels.TextChannel | Channels.GroupChannel } | - { type: "kick_member", target: Servers.Server, user: string } | - { type: "ban_member", target: Servers.Server, user: string } | - { type: "unfriend_user", target: Users.User } | - { type: "block_user", target: Users.User } | - { type: "create_channel", target: Servers.Server } -)) | -({ id: "special_input" } & ( - { type: "create_group" | "create_server" | "set_custom_status" | "add_friend" } | - { type: "create_role", server: string, callback: (id: string) => void } -)) -| { - id: "_input"; - question: Children; - field: Children; - defaultValue?: string; - callback: (value: string) => Promise<void>; - } -| { - id: "onboarding"; - callback: ( - username: string, - loginAfterSuccess?: true - ) => Promise<void>; - } - -// Pop-overs -| { id: "image_viewer"; attachment?: Attachment; embed?: EmbedImage; } -| { id: "modify_account"; field: "username" | "email" | "password" } -| { id: "profile"; user_id: string } -| { id: "channel_info"; channel_id: string } -| { id: "pending_requests"; users: string[] } -| { - id: "user_picker"; - omit?: string[]; - callback: (users: string[]) => Promise<void>; - }; + | { id: "none" } + + // Modals + | { id: "signed_out" } + | { id: "error"; error: string } + | { id: "clipboard"; text: string } + | { + id: "_prompt"; + question: Children; + content?: Children; + actions: Action[]; + } + | ({ id: "special_prompt" } & ( + | { type: "leave_group"; target: Channels.GroupChannel } + | { type: "close_dm"; target: Channels.DirectMessageChannel } + | { type: "leave_server"; target: Servers.Server } + | { type: "delete_server"; target: Servers.Server } + | { type: "delete_channel"; target: Channels.TextChannel } + | { type: "delete_message"; target: Channels.Message } + | { + type: "create_invite"; + target: Channels.TextChannel | Channels.GroupChannel; + } + | { type: "kick_member"; target: Servers.Server; user: string } + | { type: "ban_member"; target: Servers.Server; user: string } + | { type: "unfriend_user"; target: Users.User } + | { type: "block_user"; target: Users.User } + | { type: "create_channel"; target: Servers.Server } + )) + | ({ id: "special_input" } & ( + | { + type: + | "create_group" + | "create_server" + | "set_custom_status" + | "add_friend"; + } + | { + type: "create_role"; + server: string; + callback: (id: string) => void; + } + )) + | { + id: "_input"; + question: Children; + field: Children; + defaultValue?: string; + callback: (value: string) => Promise<void>; + } + | { + id: "onboarding"; + callback: ( + username: string, + loginAfterSuccess?: true, + ) => Promise<void>; + } + + // Pop-overs + | { id: "image_viewer"; attachment?: Attachment; embed?: EmbedImage } + | { id: "modify_account"; field: "username" | "email" | "password" } + | { id: "profile"; user_id: string } + | { id: "channel_info"; channel_id: string } + | { id: "pending_requests"; users: string[] } + | { + id: "user_picker"; + omit?: string[]; + callback: (users: string[]) => Promise<void>; + }; export const IntermediateContext = createContext({ - screen: { id: "none" } as Screen, - focusTaken: false + screen: { id: "none" } as Screen, + focusTaken: false, }); export const IntermediateActionsContext = createContext({ - openScreen: (screen: Screen) => {}, - writeClipboard: (text: string) => {} + openScreen: (screen: Screen) => {}, + writeClipboard: (text: string) => {}, }); interface Props { - children: Children; + children: Children; } export default function Intermediate(props: Props) { - const [screen, openScreen] = useState<Screen>({ id: "none" }); - const history = useHistory(); - - const value = { - screen, - focusTaken: screen.id !== 'none' - }; - - const actions = useMemo(() => { - return { - openScreen: (screen: Screen) => openScreen(screen), - writeClipboard: (text: string) => { - if (navigator.clipboard) { - navigator.clipboard.writeText(text); - } else { - actions.openScreen({ id: "clipboard", text }); - } - } - } - }, []); - - useEffect(() => { - const openProfile = (user_id: string) => openScreen({ id: "profile", user_id }); - const navigate = (path: string) => history.push(path); - - const subs = [ - internalSubscribe("Intermediate", "openProfile", openProfile), - internalSubscribe("Intermediate", "navigate", navigate) - ] - - return () => subs.map(unsub => unsub()); - }, []); - - return ( - <IntermediateContext.Provider value={value}> - <IntermediateActionsContext.Provider value={actions}> - { screen.id !== 'onboarding' && props.children } - <Modals - {...value} - {...actions} - key={ - screen.id - } /** By specifying a key, we reset state whenever switching screen. */ - /> - <Prompt - when={[ 'modify_account', 'special_prompt', 'special_input', 'image_viewer', 'profile', 'channel_info', 'pending_requests', 'user_picker' ].includes(screen.id)} - message={(_, action) => { - if (action === 'POP') { - openScreen({ id: 'none' }); - setTimeout(() => history.push(history.location), 0); - - return false; - } - - return true; - }} - /> - </IntermediateActionsContext.Provider> - </IntermediateContext.Provider> - ); + const [screen, openScreen] = useState<Screen>({ id: "none" }); + const history = useHistory(); + + const value = { + screen, + focusTaken: screen.id !== "none", + }; + + const actions = useMemo(() => { + return { + openScreen: (screen: Screen) => openScreen(screen), + writeClipboard: (text: string) => { + if (navigator.clipboard) { + navigator.clipboard.writeText(text); + } else { + actions.openScreen({ id: "clipboard", text }); + } + }, + }; + }, []); + + useEffect(() => { + const openProfile = (user_id: string) => + openScreen({ id: "profile", user_id }); + const navigate = (path: string) => history.push(path); + + const subs = [ + internalSubscribe("Intermediate", "openProfile", openProfile), + internalSubscribe("Intermediate", "navigate", navigate), + ]; + + return () => subs.map((unsub) => unsub()); + }, []); + + return ( + <IntermediateContext.Provider value={value}> + <IntermediateActionsContext.Provider value={actions}> + {screen.id !== "onboarding" && props.children} + <Modals + {...value} + {...actions} + key={ + screen.id + } /** By specifying a key, we reset state whenever switching screen. */ + /> + <Prompt + when={[ + "modify_account", + "special_prompt", + "special_input", + "image_viewer", + "profile", + "channel_info", + "pending_requests", + "user_picker", + ].includes(screen.id)} + message={(_, action) => { + if (action === "POP") { + openScreen({ id: "none" }); + setTimeout(() => history.push(history.location), 0); + + return false; + } + + return true; + }} + /> + </IntermediateActionsContext.Provider> + </IntermediateContext.Provider> + ); } export const useIntermediate = () => useContext(IntermediateActionsContext); diff --git a/src/context/intermediate/Modals.tsx b/src/context/intermediate/Modals.tsx index e825bcbc998cfbf4770450546efe02f2f38fd14d..5ca0c164d7a45ffa99e5f7954cd4d6fe8bc214a8 100644 --- a/src/context/intermediate/Modals.tsx +++ b/src/context/intermediate/Modals.tsx @@ -1,34 +1,33 @@ import { Screen } from "./Intermediate"; - +import { ClipboardModal } from "./modals/Clipboard"; import { ErrorModal } from "./modals/Error"; import { InputModal } from "./modals/Input"; +import { OnboardingModal } from "./modals/Onboarding"; import { PromptModal } from "./modals/Prompt"; import { SignedOutModal } from "./modals/SignedOut"; -import { ClipboardModal } from "./modals/Clipboard"; -import { OnboardingModal } from "./modals/Onboarding"; export interface Props { - screen: Screen; - openScreen: (id: any) => void; + screen: Screen; + openScreen: (id: any) => void; } export default function Modals({ screen, openScreen }: Props) { - const onClose = () => openScreen({ id: "none" }); + const onClose = () => openScreen({ id: "none" }); - switch (screen.id) { - case "_prompt": - return <PromptModal onClose={onClose} {...screen} />; - case "_input": - return <InputModal onClose={onClose} {...screen} />; - case "error": - return <ErrorModal onClose={onClose} {...screen} />; - case "signed_out": - return <SignedOutModal onClose={onClose} {...screen} />; - case "clipboard": - return <ClipboardModal onClose={onClose} {...screen} />; - case "onboarding": - return <OnboardingModal onClose={onClose} {...screen} />; - } + switch (screen.id) { + case "_prompt": + return <PromptModal onClose={onClose} {...screen} />; + case "_input": + return <InputModal onClose={onClose} {...screen} />; + case "error": + return <ErrorModal onClose={onClose} {...screen} />; + case "signed_out": + return <SignedOutModal onClose={onClose} {...screen} />; + case "clipboard": + return <ClipboardModal onClose={onClose} {...screen} />; + case "onboarding": + return <OnboardingModal onClose={onClose} {...screen} />; + } - return null; + return null; } diff --git a/src/context/intermediate/Popovers.tsx b/src/context/intermediate/Popovers.tsx index d0ee430584f150825c1e7860776ae46c0112b08e..f171f9f0851c428daa83ac5b2843e5c1e3ca5eb3 100644 --- a/src/context/intermediate/Popovers.tsx +++ b/src/context/intermediate/Popovers.tsx @@ -1,39 +1,39 @@ -import { IntermediateContext, useIntermediate } from "./Intermediate"; import { useContext } from "preact/hooks"; -import { UserPicker } from "./popovers/UserPicker"; +import { IntermediateContext, useIntermediate } from "./Intermediate"; import { SpecialInputModal } from "./modals/Input"; import { SpecialPromptModal } from "./modals/Prompt"; -import { UserProfile } from "./popovers/UserProfile"; -import { ImageViewer } from "./popovers/ImageViewer"; import { ChannelInfo } from "./popovers/ChannelInfo"; -import { PendingRequests } from "./popovers/PendingRequests"; +import { ImageViewer } from "./popovers/ImageViewer"; import { ModifyAccountModal } from "./popovers/ModifyAccount"; +import { PendingRequests } from "./popovers/PendingRequests"; +import { UserPicker } from "./popovers/UserPicker"; +import { UserProfile } from "./popovers/UserProfile"; export default function Popovers() { - const { screen } = useContext(IntermediateContext); - const { openScreen } = useIntermediate(); + const { screen } = useContext(IntermediateContext); + const { openScreen } = useIntermediate(); - const onClose = () => openScreen({ id: "none" }); + const onClose = () => openScreen({ id: "none" }); - switch (screen.id) { - case "profile": - return <UserProfile {...screen} onClose={onClose} />; - case "user_picker": - return <UserPicker {...screen} onClose={onClose} />; - case "image_viewer": - return <ImageViewer {...screen} onClose={onClose} />; - case "channel_info": - return <ChannelInfo {...screen} onClose={onClose} />; - case "pending_requests": - return <PendingRequests {...screen} onClose={onClose} />; - case "modify_account": - return <ModifyAccountModal onClose={onClose} {...screen} />; - case "special_prompt": - return <SpecialPromptModal onClose={onClose} {...screen} />; - case "special_input": - return <SpecialInputModal onClose={onClose} {...screen} />; - } + switch (screen.id) { + case "profile": + return <UserProfile {...screen} onClose={onClose} />; + case "user_picker": + return <UserPicker {...screen} onClose={onClose} />; + case "image_viewer": + return <ImageViewer {...screen} onClose={onClose} />; + case "channel_info": + return <ChannelInfo {...screen} onClose={onClose} />; + case "pending_requests": + return <PendingRequests {...screen} onClose={onClose} />; + case "modify_account": + return <ModifyAccountModal onClose={onClose} {...screen} />; + case "special_prompt": + return <SpecialPromptModal onClose={onClose} {...screen} />; + case "special_input": + return <SpecialInputModal onClose={onClose} {...screen} />; + } - return null; + return null; } diff --git a/src/context/intermediate/modals/Clipboard.tsx b/src/context/intermediate/modals/Clipboard.tsx index f0b12badba5d4d606083752f30611fdf607bf52c..8b238373ea0308cb8ff89abf2d0ae5e95dcf6a2f 100644 --- a/src/context/intermediate/modals/Clipboard.tsx +++ b/src/context/intermediate/modals/Clipboard.tsx @@ -1,32 +1,32 @@ import { Text } from "preact-i18n"; + import Modal from "../../../components/ui/Modal"; interface Props { - onClose: () => void; - text: string; + onClose: () => void; + text: string; } export function ClipboardModal({ onClose, text }: Props) { - return ( - <Modal - visible={true} - onClose={onClose} - title={<Text id="app.special.modals.clipboard.unavailable" />} - actions={[ - { - onClick: onClose, - confirmation: true, - text: <Text id="app.special.modals.actions.close" /> - } - ]} - > - {location.protocol !== "https:" && ( - <p> - <Text id="app.special.modals.clipboard.https" /> - </p> - )} - <Text id="app.special.modals.clipboard.copy" />{" "} - <code style={{ userSelect: "all" }}>{text}</code> - </Modal> - ); + return ( + <Modal + visible={true} + onClose={onClose} + title={<Text id="app.special.modals.clipboard.unavailable" />} + actions={[ + { + onClick: onClose, + confirmation: true, + text: <Text id="app.special.modals.actions.close" />, + }, + ]}> + {location.protocol !== "https:" && ( + <p> + <Text id="app.special.modals.clipboard.https" /> + </p> + )} + <Text id="app.special.modals.clipboard.copy" />{" "} + <code style={{ userSelect: "all" }}>{text}</code> + </Modal> + ); } diff --git a/src/context/intermediate/modals/Error.tsx b/src/context/intermediate/modals/Error.tsx index 5b3cc1707461fbbe9c84653863738325c6c34980..81d115255ccaf77ead5f4eed211d26df342990a6 100644 --- a/src/context/intermediate/modals/Error.tsx +++ b/src/context/intermediate/modals/Error.tsx @@ -1,30 +1,30 @@ import { Text } from "preact-i18n"; + import Modal from "../../../components/ui/Modal"; interface Props { - onClose: () => void; - error: string; + onClose: () => void; + error: string; } export function ErrorModal({ onClose, error }: Props) { - return ( - <Modal - visible={true} - onClose={() => false} - title={<Text id="app.special.modals.error" />} - actions={[ - { - onClick: onClose, - confirmation: true, - text: <Text id="app.special.modals.actions.ok" /> - }, - { - onClick: () => location.reload(), - text: <Text id="app.special.modals.actions.reload" /> - } - ]} - > - <Text id={`error.${error}`}>{error}</Text> - </Modal> - ); + return ( + <Modal + visible={true} + onClose={() => false} + title={<Text id="app.special.modals.error" />} + actions={[ + { + onClick: onClose, + confirmation: true, + text: <Text id="app.special.modals.actions.ok" />, + }, + { + onClick: () => location.reload(), + text: <Text id="app.special.modals.actions.reload" />, + }, + ]}> + <Text id={`error.${error}`}>{error}</Text> + </Modal> + ); } diff --git a/src/context/intermediate/modals/Input.tsx b/src/context/intermediate/modals/Input.tsx index d57ba8d81539a2a80e4e63217b5b0e572bc49e64..36e6026d4f1c35dbac3ea01e7db2129845d42099 100644 --- a/src/context/intermediate/modals/Input.tsx +++ b/src/context/intermediate/modals/Input.tsx @@ -1,154 +1,176 @@ +import { useHistory } from "react-router"; import { ulid } from "ulid"; + import { Text } from "preact-i18n"; -import { useHistory } from "react-router"; +import { useContext, useState } from "preact/hooks"; + +import InputBox from "../../../components/ui/InputBox"; import Modal from "../../../components/ui/Modal"; +import Overline from "../../../components/ui/Overline"; + import { Children } from "../../../types/Preact"; -import { takeError } from "../../revoltjs/util"; -import { useContext, useState } from "preact/hooks"; -import Overline from '../../../components/ui/Overline'; -import InputBox from '../../../components/ui/InputBox'; import { AppContext } from "../../revoltjs/RevoltClient"; +import { takeError } from "../../revoltjs/util"; interface Props { - onClose: () => void; - question: Children; - field?: Children; - defaultValue?: string; - callback: (value: string) => Promise<void>; + onClose: () => void; + question: Children; + field?: Children; + defaultValue?: string; + callback: (value: string) => Promise<void>; } export function InputModal({ - onClose, - question, - field, - defaultValue, - callback + onClose, + question, + field, + defaultValue, + callback, }: Props) { - const [processing, setProcessing] = useState(false); - const [value, setValue] = useState(defaultValue ?? ""); - const [error, setError] = useState<undefined | string>(undefined); + const [processing, setProcessing] = useState(false); + const [value, setValue] = useState(defaultValue ?? ""); + const [error, setError] = useState<undefined | string>(undefined); - return ( - <Modal - visible={true} - title={question} - disabled={processing} - actions={[ - { - confirmation: true, - text: <Text id="app.special.modals.actions.ok" />, - onClick: () => { - setProcessing(true); - callback(value) - .then(onClose) - .catch(err => { - setError(takeError(err)); - setProcessing(false) - }) - } - }, - { - text: <Text id="app.special.modals.actions.cancel" />, - onClick: onClose - } - ]} - onClose={onClose} - > - <form> - { field ? <Overline error={error} block> - {field} - </Overline> : (error && <Overline error={error} type="error" block />) } - <InputBox - value={value} - onChange={e => setValue(e.currentTarget.value)} - /> - </form> - </Modal> - ); + return ( + <Modal + visible={true} + title={question} + disabled={processing} + actions={[ + { + confirmation: true, + text: <Text id="app.special.modals.actions.ok" />, + onClick: () => { + setProcessing(true); + callback(value) + .then(onClose) + .catch((err) => { + setError(takeError(err)); + setProcessing(false); + }); + }, + }, + { + text: <Text id="app.special.modals.actions.cancel" />, + onClick: onClose, + }, + ]} + onClose={onClose}> + <form> + {field ? ( + <Overline error={error} block> + {field} + </Overline> + ) : ( + error && <Overline error={error} type="error" block /> + )} + <InputBox + value={value} + onChange={(e) => setValue(e.currentTarget.value)} + /> + </form> + </Modal> + ); } type SpecialProps = { onClose: () => void } & ( - { type: "create_group" | "create_server" | "set_custom_status" | "add_friend" } | - { type: "create_role", server: string, callback: (id: string) => void } -) + | { + type: + | "create_group" + | "create_server" + | "set_custom_status" + | "add_friend"; + } + | { type: "create_role"; server: string; callback: (id: string) => void } +); export function SpecialInputModal(props: SpecialProps) { - const history = useHistory(); - const client = useContext(AppContext); + const history = useHistory(); + const client = useContext(AppContext); - const { onClose } = props; - switch (props.type) { - case "create_group": { - return <InputModal - onClose={onClose} - question={<Text id="app.main.groups.create" />} - field={<Text id="app.main.groups.name" />} - callback={async name => { - const group = await client.channels.createGroup( - { - name, - nonce: ulid(), - users: [] - } - ); + const { onClose } = props; + switch (props.type) { + case "create_group": { + return ( + <InputModal + onClose={onClose} + question={<Text id="app.main.groups.create" />} + field={<Text id="app.main.groups.name" />} + callback={async (name) => { + const group = await client.channels.createGroup({ + name, + nonce: ulid(), + users: [], + }); - history.push(`/channel/${group._id}`); - }} - />; - } - case "create_server": { - return <InputModal - onClose={onClose} - question={<Text id="app.main.servers.create" />} - field={<Text id="app.main.servers.name" />} - callback={async name => { - const server = await client.servers.createServer( - { - name, - nonce: ulid() - } - ); + history.push(`/channel/${group._id}`); + }} + /> + ); + } + case "create_server": { + return ( + <InputModal + onClose={onClose} + question={<Text id="app.main.servers.create" />} + field={<Text id="app.main.servers.name" />} + callback={async (name) => { + const server = await client.servers.createServer({ + name, + nonce: ulid(), + }); - history.push(`/server/${server._id}`); - }} - />; - } - case "create_role": { - return <InputModal - onClose={onClose} - question={<Text id="app.settings.permissions.create_role" />} - field={<Text id="app.settings.permissions.role_name" />} - callback={async name => { - const role = await client.servers.createRole(props.server, name); - props.callback(role.id); - }} - />; - } - case "set_custom_status": { - return <InputModal - onClose={onClose} - question={<Text id="app.context_menu.set_custom_status" />} - field={<Text id="app.context_menu.custom_status" />} - defaultValue={client.user?.status?.text} - callback={text => - client.users.editUser({ - status: { - ...client.user?.status, - text: text.trim().length > 0 ? text : undefined - } - }) - } - />; - } - case "add_friend": { - return <InputModal - onClose={onClose} - question={"Add Friend"} - callback={username => - client.users.addFriend(username) - } - />; - } - default: return null; - } + history.push(`/server/${server._id}`); + }} + /> + ); + } + case "create_role": { + return ( + <InputModal + onClose={onClose} + question={ + <Text id="app.settings.permissions.create_role" /> + } + field={<Text id="app.settings.permissions.role_name" />} + callback={async (name) => { + const role = await client.servers.createRole( + props.server, + name, + ); + props.callback(role.id); + }} + /> + ); + } + case "set_custom_status": { + return ( + <InputModal + onClose={onClose} + question={<Text id="app.context_menu.set_custom_status" />} + field={<Text id="app.context_menu.custom_status" />} + defaultValue={client.user?.status?.text} + callback={(text) => + client.users.editUser({ + status: { + ...client.user?.status, + text: text.trim().length > 0 ? text : undefined, + }, + }) + } + /> + ); + } + case "add_friend": { + return ( + <InputModal + onClose={onClose} + question={"Add Friend"} + callback={(username) => client.users.addFriend(username)} + /> + ); + } + default: + return null; + } } diff --git a/src/context/intermediate/modals/Onboarding.tsx b/src/context/intermediate/modals/Onboarding.tsx index 3f328191a9f8bbb804cecdef303f6569c9b428b5..35934bbe0991c02e464fc239fd548d7e8b1fe4ff 100644 --- a/src/context/intermediate/modals/Onboarding.tsx +++ b/src/context/intermediate/modals/Onboarding.tsx @@ -1,71 +1,78 @@ -import { Text } from "preact-i18n"; -import { useState } from "preact/hooks"; import { SubmitHandler, useForm } from "react-hook-form"; + import styles from "./Onboarding.module.scss"; -import { takeError } from "../../revoltjs/util"; +import { Text } from "preact-i18n"; +import { useState } from "preact/hooks"; + +import wideSVG from "../../../assets/wide.svg"; import Button from "../../../components/ui/Button"; -import FormField from "../../../pages/login/FormField"; import Preloader from "../../../components/ui/Preloader"; -import wideSVG from '../../../assets/wide.svg'; +import FormField from "../../../pages/login/FormField"; +import { takeError } from "../../revoltjs/util"; interface Props { - onClose: () => void; - callback: (username: string, loginAfterSuccess?: true) => Promise<void>; + onClose: () => void; + callback: (username: string, loginAfterSuccess?: true) => Promise<void>; } interface FormInputs { - username: string + username: string; } export function OnboardingModal({ onClose, callback }: Props) { - const { handleSubmit, register } = useForm<FormInputs>(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState<string | undefined>(undefined); + const { handleSubmit, register } = useForm<FormInputs>(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | undefined>(undefined); - const onSubmit: SubmitHandler<FormInputs> = ({ username }) => { - setLoading(true); - callback(username, true) - .then(onClose) - .catch((err: any) => { - setError(takeError(err)); - setLoading(false); - }); - } + const onSubmit: SubmitHandler<FormInputs> = ({ username }) => { + setLoading(true); + callback(username, true) + .then(onClose) + .catch((err: any) => { + setError(takeError(err)); + setLoading(false); + }); + }; - return ( - <div className={styles.onboarding}> - <div className={styles.header}> - <h1> - <Text id="app.special.modals.onboarding.welcome" /> - <img src={wideSVG} /> - </h1> - </div> - <div className={styles.form}> - {loading ? ( - <Preloader type="spinner" /> - ) : ( - <> - <p> - <Text id="app.special.modals.onboarding.pick" /> - </p> - <form onSubmit={handleSubmit(onSubmit) as JSX.GenericEventHandler<HTMLFormElement>}> - <div> - <FormField - type="username" - register={register} - showOverline - error={error} - /> - </div> - <Button type="submit"> - <Text id="app.special.modals.actions.continue" /> - </Button> - </form> - </> - )} - </div> - <div /> - </div> - ); + return ( + <div className={styles.onboarding}> + <div className={styles.header}> + <h1> + <Text id="app.special.modals.onboarding.welcome" /> + <img src={wideSVG} /> + </h1> + </div> + <div className={styles.form}> + {loading ? ( + <Preloader type="spinner" /> + ) : ( + <> + <p> + <Text id="app.special.modals.onboarding.pick" /> + </p> + <form + onSubmit={ + handleSubmit( + onSubmit, + ) as JSX.GenericEventHandler<HTMLFormElement> + }> + <div> + <FormField + type="username" + register={register} + showOverline + error={error} + /> + </div> + <Button type="submit"> + <Text id="app.special.modals.actions.continue" /> + </Button> + </form> + </> + )} + </div> + <div /> + </div> + ); } diff --git a/src/context/intermediate/modals/Prompt.tsx b/src/context/intermediate/modals/Prompt.tsx index 1af00210afa6ff99c636ea4141419060ad9c9e28..dd21a9ce0f0a00627313a33bf560ba6c4c5e6777 100644 --- a/src/context/intermediate/modals/Prompt.tsx +++ b/src/context/intermediate/modals/Prompt.tsx @@ -1,346 +1,473 @@ +import { useHistory } from "react-router-dom"; +import { Channels, Servers, Users } from "revolt.js/dist/api/objects"; import { ulid } from "ulid"; + +import styles from "./Prompt.module.scss"; import { Text } from "preact-i18n"; -import styles from './Prompt.module.scss'; -import { useHistory } from "react-router-dom"; -import Radio from "../../../components/ui/Radio"; -import { Children } from "../../../types/Preact"; -import { useIntermediate } from "../Intermediate"; +import { useContext, useEffect, useState } from "preact/hooks"; + +import { TextReact } from "../../../lib/i18n"; + +import Message from "../../../components/common/messaging/Message"; +import UserIcon from "../../../components/common/user/UserIcon"; import InputBox from "../../../components/ui/InputBox"; +import Modal, { Action } from "../../../components/ui/Modal"; import Overline from "../../../components/ui/Overline"; +import Radio from "../../../components/ui/Radio"; + +import { Children } from "../../../types/Preact"; import { AppContext } from "../../revoltjs/RevoltClient"; import { mapMessage, takeError } from "../../revoltjs/util"; -import Modal, { Action } from "../../../components/ui/Modal"; -import { Channels, Servers, Users } from "revolt.js/dist/api/objects"; -import { useContext, useEffect, useState } from "preact/hooks"; -import UserIcon from "../../../components/common/user/UserIcon"; -import Message from "../../../components/common/messaging/Message"; -import { TextReact } from "../../../lib/i18n"; +import { useIntermediate } from "../Intermediate"; interface Props { - onClose: () => void; - question: Children; - content?: Children; - disabled?: boolean; - actions: Action[]; - error?: string; + onClose: () => void; + question: Children; + content?: Children; + disabled?: boolean; + actions: Action[]; + error?: string; } -export function PromptModal({ onClose, question, content, actions, disabled, error }: Props) { - return ( - <Modal - visible={true} - title={question} - actions={actions} - onClose={onClose} - disabled={disabled}> - { error && <Overline error={error} type="error" /> } - { content } - </Modal> - ); +export function PromptModal({ + onClose, + question, + content, + actions, + disabled, + error, +}: Props) { + return ( + <Modal + visible={true} + title={question} + actions={actions} + onClose={onClose} + disabled={disabled}> + {error && <Overline error={error} type="error" />} + {content} + </Modal> + ); } type SpecialProps = { onClose: () => void } & ( - { type: "leave_group", target: Channels.GroupChannel } | - { type: "close_dm", target: Channels.DirectMessageChannel } | - { type: "leave_server", target: Servers.Server } | - { type: "delete_server", target: Servers.Server } | - { type: "delete_channel", target: Channels.TextChannel } | - { type: "delete_message", target: Channels.Message } | - { type: "create_invite", target: Channels.TextChannel | Channels.GroupChannel } | - { type: "kick_member", target: Servers.Server, user: string } | - { type: "ban_member", target: Servers.Server, user: string } | - { type: "unfriend_user", target: Users.User } | - { type: "block_user", target: Users.User } | - { type: "create_channel", target: Servers.Server } -) + | { type: "leave_group"; target: Channels.GroupChannel } + | { type: "close_dm"; target: Channels.DirectMessageChannel } + | { type: "leave_server"; target: Servers.Server } + | { type: "delete_server"; target: Servers.Server } + | { type: "delete_channel"; target: Channels.TextChannel } + | { type: "delete_message"; target: Channels.Message } + | { + type: "create_invite"; + target: Channels.TextChannel | Channels.GroupChannel; + } + | { type: "kick_member"; target: Servers.Server; user: string } + | { type: "ban_member"; target: Servers.Server; user: string } + | { type: "unfriend_user"; target: Users.User } + | { type: "block_user"; target: Users.User } + | { type: "create_channel"; target: Servers.Server } +); export function SpecialPromptModal(props: SpecialProps) { - const client = useContext(AppContext); - const [ processing, setProcessing ] = useState(false); - const [ error, setError ] = useState<undefined | string>(undefined); + const client = useContext(AppContext); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState<undefined | string>(undefined); + + const { onClose } = props; + switch (props.type) { + case "leave_group": + case "close_dm": + case "leave_server": + case "delete_server": + case "delete_channel": + case "unfriend_user": + case "block_user": { + const EVENTS = { + close_dm: ["confirm_close_dm", "close"], + delete_server: ["confirm_delete", "delete"], + delete_channel: ["confirm_delete", "delete"], + leave_group: ["confirm_leave", "leave"], + leave_server: ["confirm_leave", "leave"], + unfriend_user: ["unfriend_user", "remove"], + block_user: ["block_user", "block"], + }; + + let event = EVENTS[props.type]; + let name; + switch (props.type) { + case "unfriend_user": + case "block_user": + name = props.target.username; + break; + case "close_dm": + name = client.users.get( + client.channels.getRecipient(props.target._id), + )?.username; + break; + default: + name = props.target.name; + } + + return ( + <PromptModal + onClose={onClose} + question={ + <Text + id={`app.special.modals.prompt.${event[0]}`} + fields={{ name }} + /> + } + actions={[ + { + confirmation: true, + contrast: true, + error: true, + text: ( + <Text + id={`app.special.modals.actions.${event[1]}`} + /> + ), + onClick: async () => { + setProcessing(true); - const { onClose } = props; - switch (props.type) { - case 'leave_group': - case 'close_dm': - case 'leave_server': - case 'delete_server': - case 'delete_channel': - case 'unfriend_user': - case 'block_user': { - const EVENTS = { - 'close_dm': ['confirm_close_dm', 'close'], - 'delete_server': ['confirm_delete', 'delete'], - 'delete_channel': ['confirm_delete', 'delete'], - 'leave_group': ['confirm_leave', 'leave'], - 'leave_server': ['confirm_leave', 'leave'], - 'unfriend_user': ['unfriend_user', 'remove'], - 'block_user': ['block_user', 'block'] - }; + try { + switch (props.type) { + case "unfriend_user": + await client.users.removeFriend( + props.target._id, + ); + break; + case "block_user": + await client.users.blockUser( + props.target._id, + ); + break; + case "leave_group": + case "close_dm": + case "delete_channel": + await client.channels.delete( + props.target._id, + ); + break; + case "leave_server": + case "delete_server": + await client.servers.delete( + props.target._id, + ); + break; + } - let event = EVENTS[props.type]; - let name; - switch (props.type) { - case 'unfriend_user': - case 'block_user': name = props.target.username; break; - case 'close_dm': name = client.users.get(client.channels.getRecipient(props.target._id))?.username; break; - default: name = props.target.name; - } + onClose(); + } catch (err) { + setError(takeError(err)); + setProcessing(false); + } + }, + }, + { + text: ( + <Text id="app.special.modals.actions.cancel" /> + ), + onClick: onClose, + }, + ]} + content={ + <TextReact + id={`app.special.modals.prompt.${event[0]}_long`} + fields={{ name: <b>{name}</b> }} + /> + } + disabled={processing} + error={error} + /> + ); + } + case "delete_message": { + return ( + <PromptModal + onClose={onClose} + question={<Text id={"app.context_menu.delete_message"} />} + actions={[ + { + confirmation: true, + contrast: true, + error: true, + text: ( + <Text id="app.special.modals.actions.delete" /> + ), + onClick: async () => { + setProcessing(true); - return ( - <PromptModal - onClose={onClose} - question={<Text - id={`app.special.modals.prompt.${event[0]}`} - fields={{ name }} - />} - actions={[ - { - confirmation: true, - contrast: true, - error: true, - text: <Text id={`app.special.modals.actions.${event[1]}`} />, - onClick: async () => { - setProcessing(true); + try { + await client.channels.deleteMessage( + props.target.channel, + props.target._id, + ); - try { - switch (props.type) { - case 'unfriend_user': - await client.users.removeFriend(props.target._id); break; - case 'block_user': - await client.users.blockUser(props.target._id); break; - case 'leave_group': - case 'close_dm': - case 'delete_channel': - await client.channels.delete(props.target._id); break; - case 'leave_server': - case 'delete_server': - await client.servers.delete(props.target._id); break; - } + onClose(); + } catch (err) { + setError(takeError(err)); + setProcessing(false); + } + }, + }, + { + text: ( + <Text id="app.special.modals.actions.cancel" /> + ), + onClick: onClose, + }, + ]} + content={ + <> + <Text + id={`app.special.modals.prompt.confirm_delete_message_long`} + /> + <Message + message={mapMessage(props.target)} + head={true} + contrast + /> + </> + } + disabled={processing} + error={error} + /> + ); + } + case "create_invite": { + const [code, setCode] = useState("abcdef"); + const { writeClipboard } = useIntermediate(); - onClose(); - } catch (err) { - setError(takeError(err)); - setProcessing(false); - } - } - }, - { text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose } - ]} - content={<TextReact id={`app.special.modals.prompt.${event[0]}_long`} fields={{ name: <b>{ name }</b> }} />} - disabled={processing} - error={error} - /> - ) - } - case 'delete_message': { - return ( - <PromptModal - onClose={onClose} - question={<Text id={'app.context_menu.delete_message'} />} - actions={[ - { - confirmation: true, - contrast: true, - error: true, - text: <Text id="app.special.modals.actions.delete" />, - onClick: async () => { - setProcessing(true); + useEffect(() => { + setProcessing(true); - try { - await client.channels.deleteMessage(props.target.channel, props.target._id); + client.channels + .createInvite(props.target._id) + .then((code) => setCode(code)) + .catch((err) => setError(takeError(err))) + .finally(() => setProcessing(false)); + }, []); - onClose(); - } catch (err) { - setError(takeError(err)); - setProcessing(false); - } - } - }, - { text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose } - ]} - content={<> - <Text id={`app.special.modals.prompt.confirm_delete_message_long`} /> - <Message message={mapMessage(props.target)} head={true} contrast /> - </>} - disabled={processing} - error={error} - /> - ) - } - case "create_invite": { - const [ code, setCode ] = useState('abcdef'); - const { writeClipboard } = useIntermediate(); + return ( + <PromptModal + onClose={onClose} + question={<Text id={`app.context_menu.create_invite`} />} + actions={[ + { + text: <Text id="app.special.modals.actions.ok" />, + confirmation: true, + onClick: onClose, + }, + { + text: <Text id="app.context_menu.copy_link" />, + onClick: () => + writeClipboard( + `${window.location.protocol}//${window.location.host}/invite/${code}`, + ), + }, + ]} + content={ + processing ? ( + <Text id="app.special.modals.prompt.create_invite_generate" /> + ) : ( + <div className={styles.invite}> + <Text id="app.special.modals.prompt.create_invite_created" /> + <code>{code}</code> + </div> + ) + } + disabled={processing} + error={error} + /> + ); + } + case "kick_member": { + const user = client.users.get(props.user); - useEffect(() => { - setProcessing(true); + return ( + <PromptModal + onClose={onClose} + question={<Text id={`app.context_menu.kick_member`} />} + actions={[ + { + text: <Text id="app.special.modals.actions.kick" />, + contrast: true, + error: true, + confirmation: true, + onClick: async () => { + setProcessing(true); - client.channels.createInvite(props.target._id) - .then(code => setCode(code)) - .catch(err => setError(takeError(err))) - .finally(() => setProcessing(false)); - }, []); + try { + await client.servers.members.kickMember( + props.target._id, + props.user, + ); + onClose(); + } catch (err) { + setError(takeError(err)); + setProcessing(false); + } + }, + }, + { + text: ( + <Text id="app.special.modals.actions.cancel" /> + ), + onClick: onClose, + }, + ]} + content={ + <div className={styles.column}> + <UserIcon target={user} size={64} /> + <Text + id="app.special.modals.prompt.confirm_kick" + fields={{ name: user?.username }} + /> + </div> + } + disabled={processing} + error={error} + /> + ); + } + case "ban_member": { + const [reason, setReason] = useState<string | undefined>(undefined); + const user = client.users.get(props.user); - return ( - <PromptModal - onClose={onClose} - question={<Text id={`app.context_menu.create_invite`} />} - actions={[ - { - text: <Text id="app.special.modals.actions.ok" />, - confirmation: true, - onClick: onClose - }, - { - text: <Text id="app.context_menu.copy_link" />, - onClick: () => writeClipboard(`${window.location.protocol}//${window.location.host}/invite/${code}`) - } - ]} - content={ - processing ? - <Text id="app.special.modals.prompt.create_invite_generate" /> - : <div className={styles.invite}> - <Text id="app.special.modals.prompt.create_invite_created" /> - <code>{code}</code> - </div> - } - disabled={processing} - error={error} - /> - ) - } - case "kick_member": { - const user = client.users.get(props.user); + return ( + <PromptModal + onClose={onClose} + question={<Text id={`app.context_menu.ban_member`} />} + actions={[ + { + text: <Text id="app.special.modals.actions.ban" />, + contrast: true, + error: true, + confirmation: true, + onClick: async () => { + setProcessing(true); - return ( - <PromptModal - onClose={onClose} - question={<Text id={`app.context_menu.kick_member`} />} - actions={[ - { - text: <Text id="app.special.modals.actions.kick" />, - contrast: true, - error: true, - confirmation: true, - onClick: async () => { - setProcessing(true); - - try { - await client.servers.members.kickMember(props.target._id, props.user); - onClose(); - } catch (err) { - setError(takeError(err)); - setProcessing(false); - } - } - }, - { text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose } - ]} - content={<div className={styles.column}> - <UserIcon target={user} size={64} /> - <Text - id="app.special.modals.prompt.confirm_kick" - fields={{ name: user?.username }} /> - </div>} - disabled={processing} - error={error} - /> - ) - } - case "ban_member": { - const [ reason, setReason ] = useState<string | undefined>(undefined); - const user = client.users.get(props.user); + try { + await client.servers.banUser( + props.target._id, + props.user, + { reason }, + ); + onClose(); + } catch (err) { + setError(takeError(err)); + setProcessing(false); + } + }, + }, + { + text: ( + <Text id="app.special.modals.actions.cancel" /> + ), + onClick: onClose, + }, + ]} + content={ + <div className={styles.column}> + <UserIcon target={user} size={64} /> + <Text + id="app.special.modals.prompt.confirm_ban" + fields={{ name: user?.username }} + /> + <Overline> + <Text id="app.special.modals.prompt.confirm_ban_reason" /> + </Overline> + <InputBox + value={reason ?? ""} + onChange={(e) => + setReason(e.currentTarget.value) + } + /> + </div> + } + disabled={processing} + error={error} + /> + ); + } + case "create_channel": { + const [name, setName] = useState(""); + const [type, setType] = useState<"Text" | "Voice">("Text"); + const history = useHistory(); - return ( - <PromptModal - onClose={onClose} - question={<Text id={`app.context_menu.ban_member`} />} - actions={[ - { - text: <Text id="app.special.modals.actions.ban" />, - contrast: true, - error: true, - confirmation: true, - onClick: async () => { - setProcessing(true); - - try { - await client.servers.banUser(props.target._id, props.user, { reason }); - onClose(); - } catch (err) { - setError(takeError(err)); - setProcessing(false); - } - } - }, - { text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose } - ]} - content={<div className={styles.column}> - <UserIcon target={user} size={64} /> - <Text - id="app.special.modals.prompt.confirm_ban" - fields={{ name: user?.username }} /> - <Overline><Text id="app.special.modals.prompt.confirm_ban_reason" /></Overline> - <InputBox value={reason ?? ''} onChange={e => setReason(e.currentTarget.value)} /> - </div>} - disabled={processing} - error={error} - /> - ) - } - case 'create_channel': { - const [ name, setName ] = useState(''); - const [ type, setType ] = useState<'Text' | 'Voice'>('Text'); - const history = useHistory(); + return ( + <PromptModal + onClose={onClose} + question={<Text id="app.context_menu.create_channel" />} + actions={[ + { + confirmation: true, + contrast: true, + text: ( + <Text id="app.special.modals.actions.create" /> + ), + onClick: async () => { + setProcessing(true); - return ( - <PromptModal - onClose={onClose} - question={<Text id="app.context_menu.create_channel" />} - actions={[ - { - confirmation: true, - contrast: true, - text: <Text id="app.special.modals.actions.create" />, - onClick: async () => { - setProcessing(true); + try { + const channel = + await client.servers.createChannel( + props.target._id, + { + type, + name, + nonce: ulid(), + }, + ); - try { - const channel = await client.servers.createChannel( - props.target._id, - { - type, - name, - nonce: ulid() - } - ); - - history.push(`/server/${props.target._id}/channel/${channel._id}`); - onClose(); - } catch (err) { - setError(takeError(err)); - setProcessing(false); - } - } - }, - { text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose } - ]} - content={<> - <Overline block type="subtle"><Text id="app.main.servers.channel_type" /></Overline> - <Radio checked={type === 'Text'} onSelect={() => setType('Text')}> - <Text id="app.main.servers.text_channel" /></Radio> - <Radio checked={type === 'Voice'} onSelect={() => setType('Voice')}> - <Text id="app.main.servers.voice_channel" /></Radio> - <Overline block type="subtle"><Text id="app.main.servers.channel_name" /></Overline> - <InputBox - value={name} - onChange={e => setName(e.currentTarget.value)} /> - </>} - disabled={processing} - error={error} - /> - ) - } - default: return null; - } + history.push( + `/server/${props.target._id}/channel/${channel._id}`, + ); + onClose(); + } catch (err) { + setError(takeError(err)); + setProcessing(false); + } + }, + }, + { + text: ( + <Text id="app.special.modals.actions.cancel" /> + ), + onClick: onClose, + }, + ]} + content={ + <> + <Overline block type="subtle"> + <Text id="app.main.servers.channel_type" /> + </Overline> + <Radio + checked={type === "Text"} + onSelect={() => setType("Text")}> + <Text id="app.main.servers.text_channel" /> + </Radio> + <Radio + checked={type === "Voice"} + onSelect={() => setType("Voice")}> + <Text id="app.main.servers.voice_channel" /> + </Radio> + <Overline block type="subtle"> + <Text id="app.main.servers.channel_name" /> + </Overline> + <InputBox + value={name} + onChange={(e) => setName(e.currentTarget.value)} + /> + </> + } + disabled={processing} + error={error} + /> + ); + } + default: + return null; + } } diff --git a/src/context/intermediate/modals/SignedOut.tsx b/src/context/intermediate/modals/SignedOut.tsx index b56f63856a15fe32327fd7ffd6161980b3fd5cfa..e04ba1038c4e1d6f7c2650d3bd612e723fc2e202 100644 --- a/src/context/intermediate/modals/SignedOut.tsx +++ b/src/context/intermediate/modals/SignedOut.tsx @@ -1,23 +1,24 @@ import { Text } from "preact-i18n"; + import Modal from "../../../components/ui/Modal"; interface Props { - onClose: () => void; + onClose: () => void; } export function SignedOutModal({ onClose }: Props) { - return ( - <Modal - visible={true} - onClose={onClose} - title={<Text id="app.special.modals.signed_out" />} - actions={[ - { - onClick: onClose, - confirmation: true, - text: <Text id="app.special.modals.actions.ok" /> - } - ]} - /> - ); + return ( + <Modal + visible={true} + onClose={onClose} + title={<Text id="app.special.modals.signed_out" />} + actions={[ + { + onClick: onClose, + confirmation: true, + text: <Text id="app.special.modals.actions.ok" />, + }, + ]} + /> + ); } diff --git a/src/context/intermediate/popovers/ChannelInfo.tsx b/src/context/intermediate/popovers/ChannelInfo.tsx index 8b0b7ddb49c0a8bd2932a2d04bf140b0a802041c..f10c14537589707627d3ae82eb2c94341b30aa39 100644 --- a/src/context/intermediate/popovers/ChannelInfo.tsx +++ b/src/context/intermediate/popovers/ChannelInfo.tsx @@ -1,38 +1,44 @@ import { X } from "@styled-icons/boxicons-regular"; + import styles from "./ChannelInfo.module.scss"; + import Modal from "../../../components/ui/Modal"; -import { getChannelName } from "../../revoltjs/util"; + import Markdown from "../../../components/markdown/Markdown"; import { useChannel, useForceUpdate } from "../../revoltjs/hooks"; +import { getChannelName } from "../../revoltjs/util"; interface Props { - channel_id: string; - onClose: () => void; + channel_id: string; + onClose: () => void; } export function ChannelInfo({ channel_id, onClose }: Props) { - const ctx = useForceUpdate(); - const channel = useChannel(channel_id, ctx); - if (!channel) return null; + const ctx = useForceUpdate(); + const channel = useChannel(channel_id, ctx); + if (!channel) return null; - if (channel.channel_type === "DirectMessage" || channel.channel_type === 'SavedMessages') { - onClose(); - return null; - } + if ( + channel.channel_type === "DirectMessage" || + channel.channel_type === "SavedMessages" + ) { + onClose(); + return null; + } - return ( - <Modal visible={true} onClose={onClose}> - <div className={styles.info}> - <div className={styles.header}> - <h1>{ getChannelName(ctx.client, channel, true) }</h1> - <div onClick={onClose}> - <X size={36} /> - </div> - </div> - <p> - <Markdown content={channel.description} /> - </p> - </div> - </Modal> - ); + return ( + <Modal visible={true} onClose={onClose}> + <div className={styles.info}> + <div className={styles.header}> + <h1>{getChannelName(ctx.client, channel, true)}</h1> + <div onClick={onClose}> + <X size={36} /> + </div> + </div> + <p> + <Markdown content={channel.description} /> + </p> + </div> + </Modal> + ); } diff --git a/src/context/intermediate/popovers/ImageViewer.tsx b/src/context/intermediate/popovers/ImageViewer.tsx index d0cf0cfc1dedbb6f4b987f01ceea74536bed29b6..a199ba70e11b6ceb1344568e64b821e7dac642a0 100644 --- a/src/context/intermediate/popovers/ImageViewer.tsx +++ b/src/context/intermediate/popovers/ImageViewer.tsx @@ -1,43 +1,46 @@ +import { Attachment, EmbedImage } from "revolt.js/dist/api/objects"; + import styles from "./ImageViewer.module.scss"; -import Modal from "../../../components/ui/Modal"; import { useContext, useEffect } from "preact/hooks"; -import { AppContext } from "../../revoltjs/RevoltClient"; -import { Attachment, EmbedImage } from "revolt.js/dist/api/objects"; -import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions"; + import AttachmentActions from "../../../components/common/messaging/attachments/AttachmentActions"; +import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions"; +import Modal from "../../../components/ui/Modal"; + +import { AppContext } from "../../revoltjs/RevoltClient"; interface Props { - onClose: () => void; - embed?: EmbedImage; - attachment?: Attachment; + onClose: () => void; + embed?: EmbedImage; + attachment?: Attachment; } export function ImageViewer({ attachment, embed, onClose }: Props) { - // ! FIXME: temp code - // ! add proxy function to client - function proxyImage(url: string) { - return 'https://jan.revolt.chat/proxy?url=' + encodeURIComponent(url); - } - - if (attachment && attachment.metadata.type !== "Image") return null; - const client = useContext(AppContext); + // ! FIXME: temp code + // ! add proxy function to client + function proxyImage(url: string) { + return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url); + } + + if (attachment && attachment.metadata.type !== "Image") return null; + const client = useContext(AppContext); - return ( - <Modal visible={true} onClose={onClose} noBackground> - <div className={styles.viewer}> - { attachment && - <> - <img src={client.generateFileURL(attachment)} /> - <AttachmentActions attachment={attachment} /> - </> - } - { embed && - <> - <img src={proxyImage(embed.url)} /> - <EmbedMediaActions embed={embed} /> - </> - } - </div> - </Modal> - ); + return ( + <Modal visible={true} onClose={onClose} noBackground> + <div className={styles.viewer}> + {attachment && ( + <> + <img src={client.generateFileURL(attachment)} /> + <AttachmentActions attachment={attachment} /> + </> + )} + {embed && ( + <> + <img src={proxyImage(embed.url)} /> + <EmbedMediaActions embed={embed} /> + </> + )} + </div> + </Modal> + ); } diff --git a/src/context/intermediate/popovers/ModifyAccount.tsx b/src/context/intermediate/popovers/ModifyAccount.tsx index 25477c0768b99a61149b2c12d8103afca363fb9f..b719554a39cc4d2f5d2835645edc99db4c129582 100644 --- a/src/context/intermediate/popovers/ModifyAccount.tsx +++ b/src/context/intermediate/popovers/ModifyAccount.tsx @@ -1,127 +1,134 @@ -import { Text } from "preact-i18n"; import { SubmitHandler, useForm } from "react-hook-form"; -import Modal from "../../../components/ui/Modal"; -import { takeError } from "../../revoltjs/util"; + +import { Text } from "preact-i18n"; import { useContext, useState } from "preact/hooks"; -import FormField from '../../../pages/login/FormField'; + +import Modal from "../../../components/ui/Modal"; import Overline from "../../../components/ui/Overline"; + +import FormField from "../../../pages/login/FormField"; import { AppContext } from "../../revoltjs/RevoltClient"; +import { takeError } from "../../revoltjs/util"; interface Props { - onClose: () => void; - field: "username" | "email" | "password"; + onClose: () => void; + field: "username" | "email" | "password"; } interface FormInputs { - password: string, - new_email: string, - new_username: string, - new_password: string, + password: string; + new_email: string; + new_username: string; + new_password: string; - // TODO: figure out if this is correct or not - // it wasn't in the types before this was typed but the element itself was there - current_password?: string + // TODO: figure out if this is correct or not + // it wasn't in the types before this was typed but the element itself was there + current_password?: string; } export function ModifyAccountModal({ onClose, field }: Props) { - const client = useContext(AppContext); - const { handleSubmit, register, errors } = useForm<FormInputs>(); - const [error, setError] = useState<string | undefined>(undefined); + const client = useContext(AppContext); + const { handleSubmit, register, errors } = useForm<FormInputs>(); + const [error, setError] = useState<string | undefined>(undefined); - const onSubmit: SubmitHandler<FormInputs> = async ({ - password, - new_username, - new_email, - new_password - }) => { - try { - if (field === "email") { - await client.req("POST", "/auth/change/email", { - password, - new_email - }); - onClose(); - } else if (field === "password") { - await client.req("POST", "/auth/change/password", { - password, - new_password - }); - onClose(); - } else if (field === "username") { - await client.req("PATCH", "/users/id/username", { - username: new_username, - password - }); - onClose(); - } - } catch (err) { - setError(takeError(err)); - } - } + const onSubmit: SubmitHandler<FormInputs> = async ({ + password, + new_username, + new_email, + new_password, + }) => { + try { + if (field === "email") { + await client.req("POST", "/auth/change/email", { + password, + new_email, + }); + onClose(); + } else if (field === "password") { + await client.req("POST", "/auth/change/password", { + password, + new_password, + }); + onClose(); + } else if (field === "username") { + await client.req("PATCH", "/users/id/username", { + username: new_username, + password, + }); + onClose(); + } + } catch (err) { + setError(takeError(err)); + } + }; - return ( - <Modal - visible={true} - onClose={onClose} - title={<Text id={`app.special.modals.account.change.${field}`} />} - actions={[ - { - confirmation: true, - onClick: handleSubmit(onSubmit), - text: - field === "email" ? ( - <Text id="app.special.modals.actions.send_email" /> - ) : ( - <Text id="app.special.modals.actions.update" /> - ) - }, - { - onClick: onClose, - text: <Text id="app.special.modals.actions.close" /> - } - ]} - > - {/* Preact / React typing incompatabilities */} - <form onSubmit={handleSubmit(onSubmit) as JSX.GenericEventHandler<HTMLFormElement>}> - {field === "email" && ( - <FormField - type="email" - name="new_email" - register={register} - showOverline - error={errors.new_email?.message} - /> - )} - {field === "password" && ( - <FormField - type="password" - name="new_password" - register={register} - showOverline - error={errors.new_password?.message} - /> - )} - {field === "username" && ( - <FormField - type="username" - name="new_username" - register={register} - showOverline - error={errors.new_username?.message} - /> - )} - <FormField - type="current_password" - register={register} - showOverline - error={errors.current_password?.message} - /> - {error && ( - <Overline type="error" error={error}> - <Text id="app.special.modals.account.failed" /> - </Overline> - )} - </form> - </Modal> - ); + return ( + <Modal + visible={true} + onClose={onClose} + title={<Text id={`app.special.modals.account.change.${field}`} />} + actions={[ + { + confirmation: true, + onClick: handleSubmit(onSubmit), + text: + field === "email" ? ( + <Text id="app.special.modals.actions.send_email" /> + ) : ( + <Text id="app.special.modals.actions.update" /> + ), + }, + { + onClick: onClose, + text: <Text id="app.special.modals.actions.close" />, + }, + ]}> + {/* Preact / React typing incompatabilities */} + <form + onSubmit={ + handleSubmit( + onSubmit, + ) as JSX.GenericEventHandler<HTMLFormElement> + }> + {field === "email" && ( + <FormField + type="email" + name="new_email" + register={register} + showOverline + error={errors.new_email?.message} + /> + )} + {field === "password" && ( + <FormField + type="password" + name="new_password" + register={register} + showOverline + error={errors.new_password?.message} + /> + )} + {field === "username" && ( + <FormField + type="username" + name="new_username" + register={register} + showOverline + error={errors.new_username?.message} + /> + )} + <FormField + type="current_password" + register={register} + showOverline + error={errors.current_password?.message} + /> + {error && ( + <Overline type="error" error={error}> + <Text id="app.special.modals.account.failed" /> + </Overline> + )} + </form> + </Modal> + ); } diff --git a/src/context/intermediate/popovers/PendingRequests.tsx b/src/context/intermediate/popovers/PendingRequests.tsx index 5b6a68b843d45a4fd03feb65e552e219808301b8..424e32683dfa86b4f52e2be9d787099e48cc007f 100644 --- a/src/context/intermediate/popovers/PendingRequests.tsx +++ b/src/context/intermediate/popovers/PendingRequests.tsx @@ -1,27 +1,31 @@ -import { Text } from "preact-i18n"; import styles from "./UserPicker.module.scss"; -import { useUsers } from "../../revoltjs/hooks"; +import { Text } from "preact-i18n"; + import Modal from "../../../components/ui/Modal"; + import { Friend } from "../../../pages/friends/Friend"; +import { useUsers } from "../../revoltjs/hooks"; interface Props { - users: string[]; - onClose: () => void; + users: string[]; + onClose: () => void; } export function PendingRequests({ users: ids, onClose }: Props) { - const users = useUsers(ids); + const users = useUsers(ids); - return ( - <Modal - visible={true} - title={<Text id="app.special.friends.pending" />} - onClose={onClose}> - <div className={styles.list}> - { users - .filter(x => typeof x !== 'undefined') - .map(x => <Friend user={x!} key={x!._id} />) } - </div> - </Modal> - ); + return ( + <Modal + visible={true} + title={<Text id="app.special.friends.pending" />} + onClose={onClose}> + <div className={styles.list}> + {users + .filter((x) => typeof x !== "undefined") + .map((x) => ( + <Friend user={x!} key={x!._id} /> + ))} + </div> + </Modal> + ); } diff --git a/src/context/intermediate/popovers/UserPicker.tsx b/src/context/intermediate/popovers/UserPicker.tsx index b4ab76ca90139f9cdf3eb4ac4a0e42305cd86075..09f8e403e894db108921a9b2c11fc24bbd70cc75 100644 --- a/src/context/intermediate/popovers/UserPicker.tsx +++ b/src/context/intermediate/popovers/UserPicker.tsx @@ -1,64 +1,68 @@ +import { User, Users } from "revolt.js/dist/api/objects"; + +import styles from "./UserPicker.module.scss"; import { Text } from "preact-i18n"; import { useState } from "preact/hooks"; -import styles from "./UserPicker.module.scss"; -import { useUsers } from "../../revoltjs/hooks"; -import Modal from "../../../components/ui/Modal"; -import { User, Users } from "revolt.js/dist/api/objects"; + import UserCheckbox from "../../../components/common/user/UserCheckbox"; +import Modal from "../../../components/ui/Modal"; + +import { useUsers } from "../../revoltjs/hooks"; interface Props { - omit?: string[]; - onClose: () => void; - callback: (users: string[]) => Promise<void>; + omit?: string[]; + onClose: () => void; + callback: (users: string[]) => Promise<void>; } export function UserPicker(props: Props) { - const [selected, setSelected] = useState<string[]>([]); - const omit = [...(props.omit || []), "00000000000000000000000000"]; + const [selected, setSelected] = useState<string[]>([]); + const omit = [...(props.omit || []), "00000000000000000000000000"]; - const users = useUsers(); + const users = useUsers(); - return ( - <Modal - visible={true} - title={<Text id="app.special.popovers.user_picker.select" />} - onClose={props.onClose} - actions={[ - { - text: <Text id="app.special.modals.actions.ok" />, - onClick: () => props.callback(selected).then(props.onClose) - } - ]} - > - <div className={styles.list}> - {(users.filter( - x => - x && - x.relationship === Users.Relationship.Friend && - !omit.includes(x._id) - ) as User[]) - .map(x => { - return { - ...x, - selected: selected.includes(x._id) - }; - }) - .map(x => ( - <UserCheckbox - user={x} - checked={x.selected} - onChange={v => { - if (v) { - setSelected([...selected, x._id]); - } else { - setSelected( - selected.filter(y => y !== x._id) - ); - } - }} - /> - ))} - </div> - </Modal> - ); + return ( + <Modal + visible={true} + title={<Text id="app.special.popovers.user_picker.select" />} + onClose={props.onClose} + actions={[ + { + text: <Text id="app.special.modals.actions.ok" />, + onClick: () => props.callback(selected).then(props.onClose), + }, + ]}> + <div className={styles.list}> + {( + users.filter( + (x) => + x && + x.relationship === Users.Relationship.Friend && + !omit.includes(x._id), + ) as User[] + ) + .map((x) => { + return { + ...x, + selected: selected.includes(x._id), + }; + }) + .map((x) => ( + <UserCheckbox + user={x} + checked={x.selected} + onChange={(v) => { + if (v) { + setSelected([...selected, x._id]); + } else { + setSelected( + selected.filter((y) => y !== x._id), + ); + } + }} + /> + ))} + </div> + </Modal> + ); } diff --git a/src/context/intermediate/popovers/UserProfile.tsx b/src/context/intermediate/popovers/UserProfile.tsx index e8a22e5b1ee37f4c6960f4bb0f8e7be2e0ae366d..beb2833359be5592b664a7888c64f8ffb158e9a4 100644 --- a/src/context/intermediate/popovers/UserProfile.tsx +++ b/src/context/intermediate/popovers/UserProfile.tsx @@ -1,331 +1,362 @@ -import { decodeTime } from "ulid"; +import { + Envelope, + Edit, + UserPlus, + Shield, + Money, +} from "@styled-icons/boxicons-regular"; import { Link, useHistory } from "react-router-dom"; -import { Localizer, Text } from "preact-i18n"; -import styles from "./UserProfile.module.scss"; -import Modal from "../../../components/ui/Modal"; -import { Route } from "revolt.js/dist/api/routes"; import { Users } from "revolt.js/dist/api/objects"; -import { useIntermediate } from "../Intermediate"; -import Preloader from "../../../components/ui/Preloader"; -import Tooltip from '../../../components/common/Tooltip'; -import IconButton from "../../../components/ui/IconButton"; -import Markdown from '../../../components/markdown/Markdown'; import { UserPermission } from "revolt.js/dist/api/permissions"; -import UserIcon from '../../../components/common/user/UserIcon'; -import ChannelIcon from '../../../components/common/ChannelIcon'; -import UserStatus from '../../../components/common/user/UserStatus'; -import { Envelope, Edit, UserPlus, Shield, Money } from "@styled-icons/boxicons-regular"; +import { Route } from "revolt.js/dist/api/routes"; +import { decodeTime } from "ulid"; + +import styles from "./UserProfile.module.scss"; +import { Localizer, Text } from "preact-i18n"; import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks"; -import { AppContext, ClientStatus, StatusContext } from "../../revoltjs/RevoltClient"; -import { useChannels, useForceUpdate, useUserPermission, useUsers } from "../../revoltjs/hooks"; + +import ChannelIcon from "../../../components/common/ChannelIcon"; +import Tooltip from "../../../components/common/Tooltip"; +import UserIcon from "../../../components/common/user/UserIcon"; +import UserStatus from "../../../components/common/user/UserStatus"; +import IconButton from "../../../components/ui/IconButton"; +import Modal from "../../../components/ui/Modal"; +import Preloader from "../../../components/ui/Preloader"; + +import Markdown from "../../../components/markdown/Markdown"; +import { + AppContext, + ClientStatus, + StatusContext, +} from "../../revoltjs/RevoltClient"; +import { + useChannels, + useForceUpdate, + useUserPermission, + useUsers, +} from "../../revoltjs/hooks"; +import { useIntermediate } from "../Intermediate"; interface Props { - user_id: string; - dummy?: boolean; - onClose: () => void; - dummyProfile?: Users.Profile; + user_id: string; + dummy?: boolean; + onClose: () => void; + dummyProfile?: Users.Profile; } enum Badges { - Developer = 1, - Translator = 2, - Supporter = 4, - ResponsibleDisclosure = 8, - EarlyAdopter = 256 + Developer = 1, + Translator = 2, + Supporter = 4, + ResponsibleDisclosure = 8, + EarlyAdopter = 256, } export function UserProfile({ user_id, onClose, dummy, dummyProfile }: Props) { - const { openScreen, writeClipboard } = useIntermediate(); + const { openScreen, writeClipboard } = useIntermediate(); + + const [profile, setProfile] = useState<undefined | null | Users.Profile>( + undefined, + ); + const [mutual, setMutual] = useState< + undefined | null | Route<"GET", "/users/id/mutual">["response"] + >(undefined); + + const history = useHistory(); + const client = useContext(AppContext); + const status = useContext(StatusContext); + const [tab, setTab] = useState("profile"); - const [profile, setProfile] = useState<undefined | null | Users.Profile>( - undefined - ); - const [mutual, setMutual] = useState< - undefined | null | Route<"GET", "/users/id/mutual">["response"] - >(undefined); + const ctx = useForceUpdate(); + const all_users = useUsers(undefined, ctx); + const channels = useChannels(undefined, ctx); - const history = useHistory(); - const client = useContext(AppContext); - const status = useContext(StatusContext); - const [tab, setTab] = useState("profile"); + const user = all_users.find((x) => x!._id === user_id); + const users = mutual?.users + ? all_users.filter((x) => mutual.users.includes(x!._id)) + : undefined; - const ctx = useForceUpdate(); - const all_users = useUsers(undefined, ctx); - const channels = useChannels(undefined, ctx); - - const user = all_users.find(x => x!._id === user_id); - const users = mutual?.users ? all_users.filter(x => mutual.users.includes(x!._id)) : undefined; - - if (!user) { - useEffect(onClose, []); - return null; - } + if (!user) { + useEffect(onClose, []); + return null; + } - const permissions = useUserPermission(user!._id, ctx); + const permissions = useUserPermission(user!._id, ctx); - useLayoutEffect(() => { - if (!user_id) return; - if (typeof profile !== 'undefined') setProfile(undefined); - if (typeof mutual !== 'undefined') setMutual(undefined); - }, [user_id]); + useLayoutEffect(() => { + if (!user_id) return; + if (typeof profile !== "undefined") setProfile(undefined); + if (typeof mutual !== "undefined") setMutual(undefined); + }, [user_id]); - if (dummy) { - useLayoutEffect(() => { - setProfile(dummyProfile); - }, [dummyProfile]); - } + if (dummy) { + useLayoutEffect(() => { + setProfile(dummyProfile); + }, [dummyProfile]); + } - useEffect(() => { - if (dummy) return; - if ( - status === ClientStatus.ONLINE && - typeof mutual === "undefined" - ) { - setMutual(null); - client.users - .fetchMutual(user_id) - .then(data => setMutual(data)); - } - }, [mutual, status]); + useEffect(() => { + if (dummy) return; + if (status === ClientStatus.ONLINE && typeof mutual === "undefined") { + setMutual(null); + client.users.fetchMutual(user_id).then((data) => setMutual(data)); + } + }, [mutual, status]); - useEffect(() => { - if (dummy) return; - if ( - status === ClientStatus.ONLINE && - typeof profile === "undefined" - ) { - setProfile(null); + useEffect(() => { + if (dummy) return; + if (status === ClientStatus.ONLINE && typeof profile === "undefined") { + setProfile(null); - if (permissions & UserPermission.ViewProfile) { - client.users - .fetchProfile(user_id) - .then(data => setProfile(data)) - .catch(() => {}); - } - } - }, [profile, status]); + if (permissions & UserPermission.ViewProfile) { + client.users + .fetchProfile(user_id) + .then((data) => setProfile(data)) + .catch(() => {}); + } + } + }, [profile, status]); - const mutualGroups = channels.filter( - channel => - channel?.channel_type === "Group" && - channel.recipients.includes(user_id) - ); + const mutualGroups = channels.filter( + (channel) => + channel?.channel_type === "Group" && + channel.recipients.includes(user_id), + ); - const backgroundURL = profile && client.users.getBackgroundURL(profile, { width: 1000 }, true); - const badges = (user.badges ?? 0) | (decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0); + const backgroundURL = + profile && + client.users.getBackgroundURL(profile, { width: 1000 }, true); + const badges = + (user.badges ?? 0) | + (decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0); - return ( - <Modal visible - border={dummy} - padding={false} - onClose={onClose} - dontModal={dummy}> - <div - className={styles.header} - data-force={ - profile?.background - ? "light" - : undefined - } - style={{ - backgroundImage: backgroundURL && `linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')` - }} - > - <div className={styles.profile}> - <UserIcon size={80} target={user} status /> - <div className={styles.details}> - <Localizer> - <span - className={styles.username} - onClick={() => writeClipboard(user.username)}> - @{user.username} - </span> - </Localizer> - {user.status?.text && ( - <span className={styles.status}> - <UserStatus user={user} /> - </span> - )} - </div> - {user.relationship === Users.Relationship.Friend && ( - <Localizer> - <Tooltip - content={ - <Text id="app.context_menu.message_user" /> - } - > - <IconButton - onClick={() => { - onClose(); - history.push(`/open/${user_id}`); - }}> - <Envelope size={30} /> - </IconButton> - </Tooltip> - </Localizer> - )} - {user.relationship === Users.Relationship.User && ( - <IconButton - onClick={() => { - onClose(); - if (dummy) return; - history.push(`/settings/profile`); - }}> - <Edit size={28} /> - </IconButton> - )} - {(user.relationship === Users.Relationship.Incoming || - user.relationship === Users.Relationship.None) && ( - <IconButton onClick={() => client.users.addFriend(user.username)}> - <UserPlus size={28} /> - </IconButton> - )} - </div> - <div className={styles.tabs}> - <div - data-active={tab === "profile"} - onClick={() => setTab("profile")} - > - <Text id="app.special.popovers.user_profile.profile" /> - </div> - { user.relationship !== Users.Relationship.User && - <> - <div - data-active={tab === "friends"} - onClick={() => setTab("friends")} - > - <Text id="app.special.popovers.user_profile.mutual_friends" /> - </div> - <div - data-active={tab === "groups"} - onClick={() => setTab("groups")} - > - <Text id="app.special.popovers.user_profile.mutual_groups" /> - </div> - </> - } - </div> - </div> - <div className={styles.content}> - {tab === "profile" && - <div> - { !(profile?.content || (badges > 0)) && - <div className={styles.empty}><Text id="app.special.popovers.user_profile.empty" /></div> } - { (badges > 0) && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.badges" /></div> } - { (badges > 0) && ( - <div className={styles.badges}> - <Localizer> - {badges & Badges.Developer ? ( - <Tooltip - content={ - <Text id="app.navigation.tabs.dev" /> - } - > - <img src="/assets/badges/developer.svg" /> - </Tooltip> - ) : ( - <></> - )} - {badges & Badges.Translator ? ( - <Tooltip - content={ - <Text id="app.special.popovers.user_profile.badges.translator" /> - } - > - <img src="/assets/badges/translator.svg" /> - </Tooltip> - ) : ( - <></> - )} - {badges & Badges.EarlyAdopter ? ( - <Tooltip - content={ - <Text id="app.special.popovers.user_profile.badges.early_adopter" /> - } - > - <img src="/assets/badges/early_adopter.svg" /> - </Tooltip> - ) : ( - <></> - )} - {badges & Badges.Supporter ? ( - <Tooltip - content={ - <Text id="app.special.popovers.user_profile.badges.supporter" /> - } - > - <Money size={32} color="#efab44" /> - </Tooltip> - ) : ( - <></> - )} - {badges & Badges.ResponsibleDisclosure ? ( - <Tooltip - content={ - <Text id="app.special.popovers.user_profile.badges.responsible_disclosure" /> - } - > - <Shield size={32} color="gray" /> - </Tooltip> - ) : ( - <></> - )} - </Localizer> - </div> - )} - { profile?.content && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.information" /></div> } - <Markdown content={profile?.content} /> - {/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/} - </div>} - {tab === "friends" && - (users ? ( - <div className={styles.entries}> - {users.length === 0 ? ( - <div className={styles.empty}> - <Text id="app.special.popovers.user_profile.no_users" /> - </div> - ) : ( - users.map( - x => - x && ( - <div onClick={() => openScreen({ id: 'profile', user_id: x._id })} - className={styles.entry} - key={x._id}> - <UserIcon size={32} target={x} /> - <span>{x.username}</span> - </div> - ) - ) - )} - </div> - ) : ( - <Preloader type="ring" /> - ))} - {tab === "groups" && ( - <div className={styles.entries}> - {mutualGroups.length === 0 ? ( - <div className={styles.empty}> - <Text id="app.special.popovers.user_profile.no_groups" /> - </div> - ) : ( - mutualGroups.map( - x => - x?.channel_type === "Group" && ( - <Link to={`/channel/${x._id}`}> - <div - className={styles.entry} - key={x._id} - > - <ChannelIcon target={x} size={32} /> - <span>{x.name}</span> - </div> - </Link> - ) - ) - )} - </div> - )} - </div> - </Modal> - ); + return ( + <Modal + visible + border={dummy} + padding={false} + onClose={onClose} + dontModal={dummy}> + <div + className={styles.header} + data-force={profile?.background ? "light" : undefined} + style={{ + backgroundImage: + backgroundURL && + `linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')`, + }}> + <div className={styles.profile}> + <UserIcon size={80} target={user} status /> + <div className={styles.details}> + <Localizer> + <span + className={styles.username} + onClick={() => writeClipboard(user.username)}> + @{user.username} + </span> + </Localizer> + {user.status?.text && ( + <span className={styles.status}> + <UserStatus user={user} /> + </span> + )} + </div> + {user.relationship === Users.Relationship.Friend && ( + <Localizer> + <Tooltip + content={ + <Text id="app.context_menu.message_user" /> + }> + <IconButton + onClick={() => { + onClose(); + history.push(`/open/${user_id}`); + }}> + <Envelope size={30} /> + </IconButton> + </Tooltip> + </Localizer> + )} + {user.relationship === Users.Relationship.User && ( + <IconButton + onClick={() => { + onClose(); + if (dummy) return; + history.push(`/settings/profile`); + }}> + <Edit size={28} /> + </IconButton> + )} + {(user.relationship === Users.Relationship.Incoming || + user.relationship === Users.Relationship.None) && ( + <IconButton + onClick={() => + client.users.addFriend(user.username) + }> + <UserPlus size={28} /> + </IconButton> + )} + </div> + <div className={styles.tabs}> + <div + data-active={tab === "profile"} + onClick={() => setTab("profile")}> + <Text id="app.special.popovers.user_profile.profile" /> + </div> + {user.relationship !== Users.Relationship.User && ( + <> + <div + data-active={tab === "friends"} + onClick={() => setTab("friends")}> + <Text id="app.special.popovers.user_profile.mutual_friends" /> + </div> + <div + data-active={tab === "groups"} + onClick={() => setTab("groups")}> + <Text id="app.special.popovers.user_profile.mutual_groups" /> + </div> + </> + )} + </div> + </div> + <div className={styles.content}> + {tab === "profile" && ( + <div> + {!(profile?.content || badges > 0) && ( + <div className={styles.empty}> + <Text id="app.special.popovers.user_profile.empty" /> + </div> + )} + {badges > 0 && ( + <div className={styles.category}> + <Text id="app.special.popovers.user_profile.sub.badges" /> + </div> + )} + {badges > 0 && ( + <div className={styles.badges}> + <Localizer> + {badges & Badges.Developer ? ( + <Tooltip + content={ + <Text id="app.navigation.tabs.dev" /> + }> + <img src="/assets/badges/developer.svg" /> + </Tooltip> + ) : ( + <></> + )} + {badges & Badges.Translator ? ( + <Tooltip + content={ + <Text id="app.special.popovers.user_profile.badges.translator" /> + }> + <img src="/assets/badges/translator.svg" /> + </Tooltip> + ) : ( + <></> + )} + {badges & Badges.EarlyAdopter ? ( + <Tooltip + content={ + <Text id="app.special.popovers.user_profile.badges.early_adopter" /> + }> + <img src="/assets/badges/early_adopter.svg" /> + </Tooltip> + ) : ( + <></> + )} + {badges & Badges.Supporter ? ( + <Tooltip + content={ + <Text id="app.special.popovers.user_profile.badges.supporter" /> + }> + <Money size={32} color="#efab44" /> + </Tooltip> + ) : ( + <></> + )} + {badges & Badges.ResponsibleDisclosure ? ( + <Tooltip + content={ + <Text id="app.special.popovers.user_profile.badges.responsible_disclosure" /> + }> + <Shield size={32} color="gray" /> + </Tooltip> + ) : ( + <></> + )} + </Localizer> + </div> + )} + {profile?.content && ( + <div className={styles.category}> + <Text id="app.special.popovers.user_profile.sub.information" /> + </div> + )} + <Markdown content={profile?.content} /> + {/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/} + </div> + )} + {tab === "friends" && + (users ? ( + <div className={styles.entries}> + {users.length === 0 ? ( + <div className={styles.empty}> + <Text id="app.special.popovers.user_profile.no_users" /> + </div> + ) : ( + users.map( + (x) => + x && ( + <div + onClick={() => + openScreen({ + id: "profile", + user_id: x._id, + }) + } + className={styles.entry} + key={x._id}> + <UserIcon + size={32} + target={x} + /> + <span>{x.username}</span> + </div> + ), + ) + )} + </div> + ) : ( + <Preloader type="ring" /> + ))} + {tab === "groups" && ( + <div className={styles.entries}> + {mutualGroups.length === 0 ? ( + <div className={styles.empty}> + <Text id="app.special.popovers.user_profile.no_groups" /> + </div> + ) : ( + mutualGroups.map( + (x) => + x?.channel_type === "Group" && ( + <Link to={`/channel/${x._id}`}> + <div + className={styles.entry} + key={x._id}> + <ChannelIcon + target={x} + size={32} + /> + <span>{x.name}</span> + </div> + </Link> + ), + ) + )} + </div> + )} + </div> + </Modal> + ); } diff --git a/src/context/revoltjs/CheckAuth.tsx b/src/context/revoltjs/CheckAuth.tsx index 1951985efed70317a1f87f1d24a26a260954d90e..0865a786a7e735d96f571ec68eff6fd9fd52c423 100644 --- a/src/context/revoltjs/CheckAuth.tsx +++ b/src/context/revoltjs/CheckAuth.tsx @@ -1,22 +1,23 @@ -import { useContext } from "preact/hooks"; import { Redirect } from "react-router-dom"; -import { Children } from "../../types/Preact"; +import { useContext } from "preact/hooks"; + +import { Children } from "../../types/Preact"; import { OperationsContext } from "./RevoltClient"; interface Props { - auth?: boolean; - children: Children; + auth?: boolean; + children: Children; } export const CheckAuth = (props: Props) => { - const operations = useContext(OperationsContext); + const operations = useContext(OperationsContext); - if (props.auth && !operations.ready()) { - return <Redirect to="/login" />; - } else if (!props.auth && operations.ready()) { - return <Redirect to="/" />; - } + if (props.auth && !operations.ready()) { + return <Redirect to="/login" />; + } else if (!props.auth && operations.ready()) { + return <Redirect to="/" />; + } - return <>{props.children}</>; + return <>{props.children}</>; }; diff --git a/src/context/revoltjs/FileUploads.tsx b/src/context/revoltjs/FileUploads.tsx index 87781e2f2ef9cb78b2c59dc995010a693446db34..14b55f2e2b7ceeed454732386108032d00f99236 100644 --- a/src/context/revoltjs/FileUploads.tsx +++ b/src/context/revoltjs/FileUploads.tsx @@ -1,221 +1,292 @@ -import { Text } from "preact-i18n"; -import { takeError } from "./util"; -import classNames from "classnames"; -import { AppContext } from "./RevoltClient"; -import styles from './FileUploads.module.scss'; +import { Plus, X, XCircle } from "@styled-icons/boxicons-regular"; +import { Pencil } from "@styled-icons/boxicons-solid"; import Axios, { AxiosRequestConfig } from "axios"; + +import styles from "./FileUploads.module.scss"; +import classNames from "classnames"; +import { Text } from "preact-i18n"; import { useContext, useEffect, useState } from "preact/hooks"; -import Preloader from "../../components/ui/Preloader"; + import { determineFileSize } from "../../lib/fileSize"; -import IconButton from '../../components/ui/IconButton'; -import { Plus, X, XCircle } from "@styled-icons/boxicons-regular"; -import { Pencil } from "@styled-icons/boxicons-solid"; + +import IconButton from "../../components/ui/IconButton"; +import Preloader from "../../components/ui/Preloader"; + import { useIntermediate } from "../intermediate/Intermediate"; +import { AppContext } from "./RevoltClient"; +import { takeError } from "./util"; type Props = { - maxFileSize: number - remove: () => Promise<void> - fileType: 'backgrounds' | 'icons' | 'avatars' | 'attachments' | 'banners' + maxFileSize: number; + remove: () => Promise<void>; + fileType: "backgrounds" | "icons" | "avatars" | "attachments" | "banners"; } & ( - { behaviour: 'ask', onChange: (file: File) => void } | - { behaviour: 'upload', onUpload: (id: string) => Promise<void> } | - { behaviour: 'multi', onChange: (files: File[]) => void, append?: (files: File[]) => void } -) & ( - { style: 'icon' | 'banner', defaultPreview?: string, previewURL?: string, width?: number, height?: number } | - { style: 'attachment', attached: boolean, uploading: boolean, cancel: () => void, size?: number } -) - -export async function uploadFile(autumnURL: string, tag: string, file: File, config?: AxiosRequestConfig) { - const formData = new FormData(); - formData.append("file", file); - - const res = await Axios.post(autumnURL + "/" + tag, formData, { - headers: { - "Content-Type": "multipart/form-data" - }, - ...config - }); - - return res.data.id; + | { behaviour: "ask"; onChange: (file: File) => void } + | { behaviour: "upload"; onUpload: (id: string) => Promise<void> } + | { + behaviour: "multi"; + onChange: (files: File[]) => void; + append?: (files: File[]) => void; + } +) & + ( + | { + style: "icon" | "banner"; + defaultPreview?: string; + previewURL?: string; + width?: number; + height?: number; + } + | { + style: "attachment"; + attached: boolean; + uploading: boolean; + cancel: () => void; + size?: number; + } + ); + +export async function uploadFile( + autumnURL: string, + tag: string, + file: File, + config?: AxiosRequestConfig, +) { + const formData = new FormData(); + formData.append("file", file); + + const res = await Axios.post(autumnURL + "/" + tag, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + ...config, + }); + + return res.data.id; } -export function grabFiles(maxFileSize: number, cb: (files: File[]) => void, tooLarge: () => void, multiple?: boolean) { - const input = document.createElement("input"); - input.type = "file"; - input.multiple = multiple ?? false; +export function grabFiles( + maxFileSize: number, + cb: (files: File[]) => void, + tooLarge: () => void, + multiple?: boolean, +) { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = multiple ?? false; - input.onchange = async (e) => { - const files = (e.currentTarget as HTMLInputElement)?.files; - if (!files) return; - for (let file of files) { - if (file.size > maxFileSize) { - return tooLarge(); - } - } + input.onchange = async (e) => { + const files = (e.currentTarget as HTMLInputElement)?.files; + if (!files) return; + for (let file of files) { + if (file.size > maxFileSize) { + return tooLarge(); + } + } - cb(Array.from(files)); - }; + cb(Array.from(files)); + }; - input.click(); + input.click(); } export function FileUploader(props: Props) { - const { fileType, maxFileSize, remove } = props; - const { openScreen } = useIntermediate(); - const client = useContext(AppContext); - - const [ uploading, setUploading ] = useState(false); - - function onClick() { - if (uploading) return; - - grabFiles(maxFileSize, async files => { - setUploading(true); - - try { - if (props.behaviour === 'multi') { - props.onChange(files); - } else if (props.behaviour === 'ask') { - props.onChange(files[0]); - } else { - await props.onUpload(await uploadFile(client.configuration!.features.autumn.url, fileType, files[0])); - } - } catch (err) { - return openScreen({ id: "error", error: takeError(err) }); - } finally { - setUploading(false); - } - }, () => - openScreen({ id: "error", error: "FileTooLarge" }), - props.behaviour === 'multi'); - } - - function removeOrUpload() { - if (uploading) return; - - if (props.style === 'attachment') { - if (props.attached) { - props.remove(); - } else { - onClick(); - } - } else { - if (props.previewURL) { - props.remove(); - } else { - onClick(); - } - } - } - - if (props.behaviour === 'multi' && props.append) { - useEffect(() => { - // File pasting. - function paste(e: ClipboardEvent) { - const items = e.clipboardData?.items; - if (typeof items === "undefined") return; - if (props.behaviour !== 'multi' || !props.append) return; - - let files = []; - for (const item of items) { - if (!item.type.startsWith("text/")) { - const blob = item.getAsFile(); - if (blob) { - if (blob.size > props.maxFileSize) { - openScreen({ id: 'error', error: 'FileTooLarge' }); - } - - files.push(blob); - } - } - } - - props.append(files); - } - - // Let the browser know we can drop files. - function dragover(e: DragEvent) { - e.stopPropagation(); - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; - } - - // File dropping. - function drop(e: DragEvent) { - e.preventDefault(); - if (props.behaviour !== 'multi' || !props.append) return; - - const dropped = e.dataTransfer?.files; - if (dropped) { - let files = []; - for (const item of dropped) { - if (item.size > props.maxFileSize) { - openScreen({ id: 'error', error: 'FileTooLarge' }); - } - - files.push(item); - } - - props.append(files); - } - } - - document.addEventListener("paste", paste); - document.addEventListener("dragover", dragover); - document.addEventListener("drop", drop); - - return () => { - document.removeEventListener("paste", paste); - document.removeEventListener("dragover", dragover); - document.removeEventListener("drop", drop); - }; - }, [ props.append ]); - } - - if (props.style === 'icon' || props.style === 'banner') { - const { style, previewURL, defaultPreview, width, height } = props; - return ( - <div className={classNames(styles.uploader, - { [styles.icon]: style === 'icon', - [styles.banner]: style === 'banner' })} - data-uploading={uploading}> - <div className={styles.image} - style={{ backgroundImage: - style === 'icon' ? `url('${previewURL ?? defaultPreview}')` : - (previewURL ? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')` : 'black'), - width, height - }} - onClick={onClick}> - { uploading ? - <div className={styles.uploading}> - <Preloader type="ring" /> - </div> : - <div className={styles.edit}> - <Pencil size={30} /> - </div> } - </div> - <div className={styles.modify}> - <span onClick={removeOrUpload}>{ - uploading ? <Text id="app.main.channel.uploading_file" /> : - props.previewURL ? <Text id="app.settings.actions.remove" /> : - <Text id="app.settings.actions.upload" /> }</span> - <span className={styles.small}><Text id="app.settings.actions.max_filesize" fields={{ filesize: determineFileSize(maxFileSize) }} /></span> - </div> - </div> - ) - } else if (props.style === 'attachment') { - const { attached, uploading, cancel, size } = props; - return ( - <IconButton - onClick={() => { - if (uploading) return cancel(); - if (attached) return remove(); - onClick(); - }}> - { uploading ? <XCircle size={size} /> : attached ? <X size={size} /> : <Plus size={size} />} - </IconButton> - ) - } - - return null; + const { fileType, maxFileSize, remove } = props; + const { openScreen } = useIntermediate(); + const client = useContext(AppContext); + + const [uploading, setUploading] = useState(false); + + function onClick() { + if (uploading) return; + + grabFiles( + maxFileSize, + async (files) => { + setUploading(true); + + try { + if (props.behaviour === "multi") { + props.onChange(files); + } else if (props.behaviour === "ask") { + props.onChange(files[0]); + } else { + await props.onUpload( + await uploadFile( + client.configuration!.features.autumn.url, + fileType, + files[0], + ), + ); + } + } catch (err) { + return openScreen({ id: "error", error: takeError(err) }); + } finally { + setUploading(false); + } + }, + () => openScreen({ id: "error", error: "FileTooLarge" }), + props.behaviour === "multi", + ); + } + + function removeOrUpload() { + if (uploading) return; + + if (props.style === "attachment") { + if (props.attached) { + props.remove(); + } else { + onClick(); + } + } else { + if (props.previewURL) { + props.remove(); + } else { + onClick(); + } + } + } + + if (props.behaviour === "multi" && props.append) { + useEffect(() => { + // File pasting. + function paste(e: ClipboardEvent) { + const items = e.clipboardData?.items; + if (typeof items === "undefined") return; + if (props.behaviour !== "multi" || !props.append) return; + + let files = []; + for (const item of items) { + if (!item.type.startsWith("text/")) { + const blob = item.getAsFile(); + if (blob) { + if (blob.size > props.maxFileSize) { + openScreen({ + id: "error", + error: "FileTooLarge", + }); + } + + files.push(blob); + } + } + } + + props.append(files); + } + + // Let the browser know we can drop files. + function dragover(e: DragEvent) { + e.stopPropagation(); + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; + } + + // File dropping. + function drop(e: DragEvent) { + e.preventDefault(); + if (props.behaviour !== "multi" || !props.append) return; + + const dropped = e.dataTransfer?.files; + if (dropped) { + let files = []; + for (const item of dropped) { + if (item.size > props.maxFileSize) { + openScreen({ id: "error", error: "FileTooLarge" }); + } + + files.push(item); + } + + props.append(files); + } + } + + document.addEventListener("paste", paste); + document.addEventListener("dragover", dragover); + document.addEventListener("drop", drop); + + return () => { + document.removeEventListener("paste", paste); + document.removeEventListener("dragover", dragover); + document.removeEventListener("drop", drop); + }; + }, [props.append]); + } + + if (props.style === "icon" || props.style === "banner") { + const { style, previewURL, defaultPreview, width, height } = props; + return ( + <div + className={classNames(styles.uploader, { + [styles.icon]: style === "icon", + [styles.banner]: style === "banner", + })} + data-uploading={uploading}> + <div + className={styles.image} + style={{ + backgroundImage: + style === "icon" + ? `url('${previewURL ?? defaultPreview}')` + : previewURL + ? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')` + : "black", + width, + height, + }} + onClick={onClick}> + {uploading ? ( + <div className={styles.uploading}> + <Preloader type="ring" /> + </div> + ) : ( + <div className={styles.edit}> + <Pencil size={30} /> + </div> + )} + </div> + <div className={styles.modify}> + <span onClick={removeOrUpload}> + {uploading ? ( + <Text id="app.main.channel.uploading_file" /> + ) : props.previewURL ? ( + <Text id="app.settings.actions.remove" /> + ) : ( + <Text id="app.settings.actions.upload" /> + )} + </span> + <span className={styles.small}> + <Text + id="app.settings.actions.max_filesize" + fields={{ + filesize: determineFileSize(maxFileSize), + }} + /> + </span> + </div> + </div> + ); + } else if (props.style === "attachment") { + const { attached, uploading, cancel, size } = props; + return ( + <IconButton + onClick={() => { + if (uploading) return cancel(); + if (attached) return remove(); + onClick(); + }}> + {uploading ? ( + <XCircle size={size} /> + ) : attached ? ( + <X size={size} /> + ) : ( + <Plus size={size} /> + )} + </IconButton> + ); + } + + return null; } diff --git a/src/context/revoltjs/Notifications.tsx b/src/context/revoltjs/Notifications.tsx index 4ae6c74a61da7fd3fcf025d3c8aa0ff020847673..66e03059cacd44885eef9e0d27e839b3fba95859 100644 --- a/src/context/revoltjs/Notifications.tsx +++ b/src/context/revoltjs/Notifications.tsx @@ -1,247 +1,286 @@ -import { decodeTime } from "ulid"; -import { SoundContext } from "../Settings"; -import { AppContext } from "./RevoltClient"; -import { useTranslation } from "../../lib/i18n"; +import { Route, Switch, useHistory, useParams } from "react-router-dom"; +import { Message, SYSTEM_USER_ID, User } from "revolt.js"; import { Users } from "revolt.js/dist/api/objects"; +import { decodeTime } from "ulid"; + import { useContext, useEffect } from "preact/hooks"; + +import { useTranslation } from "../../lib/i18n"; + import { connectState } from "../../redux/connector"; -import { Message, SYSTEM_USER_ID, User } from "revolt.js"; +import { + getNotificationState, + Notifications, + shouldNotify, +} from "../../redux/reducers/notifications"; import { NotificationOptions } from "../../redux/reducers/settings"; -import { Route, Switch, useHistory, useParams } from "react-router-dom"; -import { getNotificationState, Notifications, shouldNotify } from "../../redux/reducers/notifications"; + +import { SoundContext } from "../Settings"; +import { AppContext } from "./RevoltClient"; interface Props { - options?: NotificationOptions; - notifs: Notifications; + options?: NotificationOptions; + notifs: Notifications; } const notifications: { [key: string]: Notification } = {}; -async function createNotification(title: string, options: globalThis.NotificationOptions) { - try { - return new Notification(title, options); - } catch (err) { - let sw = await navigator.serviceWorker.getRegistration(); - sw?.showNotification(title, options); - } +async function createNotification( + title: string, + options: globalThis.NotificationOptions, +) { + try { + return new Notification(title, options); + } catch (err) { + let sw = await navigator.serviceWorker.getRegistration(); + sw?.showNotification(title, options); + } } function Notifier({ options, notifs }: Props) { - const translate = useTranslation(); - const showNotification = options?.desktopEnabled ?? false; - - const client = useContext(AppContext); - const { guild: guild_id, channel: channel_id } = useParams<{ - guild: string; - channel: string; - }>(); - const history = useHistory(); - const playSound = useContext(SoundContext); - - async function message(msg: Message) { - if (msg.author === client.user!._id) return; - if (msg.channel === channel_id && document.hasFocus()) return; - if (client.user!.status?.presence === Users.Presence.Busy) return; - - const channel = client.channels.get(msg.channel); - const author = client.users.get(msg.author); - if (!channel) return; - if (author?.relationship === Users.Relationship.Blocked) return; - - const notifState = getNotificationState(notifs, channel); - if (!shouldNotify(notifState, msg, client.user!._id)) return; - - playSound('message'); - if (!showNotification) return; - - let title; - switch (channel.channel_type) { - case "SavedMessages": - return; - case "DirectMessage": - title = `@${author?.username}`; - break; - case "Group": - if (author?._id === SYSTEM_USER_ID) { - title = channel.name; - } else { - title = `@${author?.username} - ${channel.name}`; - } - break; - case "TextChannel": - const server = client.servers.get(channel.server); - title = `@${author?.username} (#${channel.name}, ${server?.name})`; - break; - default: - title = msg.channel; - break; - } - - let image; - if (msg.attachments) { - let imageAttachment = msg.attachments.find(x => x.metadata.type === 'Image'); - if (imageAttachment) { - image = client.generateFileURL(imageAttachment, { max_side: 720 }); - } - } - - let body, icon; - if (typeof msg.content === "string") { - body = client.markdownToText(msg.content); - icon = client.users.getAvatarURL(msg.author, { max_side: 256 }); - } else { - let users = client.users; - switch (msg.content.type) { - case "user_added": - case "user_remove": - body = translate( - `app.main.channel.system.${msg.content.type === 'user_added' ? 'added_by' : 'removed_by'}`, - { user: users.get(msg.content.id)?.username, other_user: users.get(msg.content.by)?.username } - ); - icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 }); - break; - case "user_joined": - case "user_left": - case "user_kicked": - case "user_banned": - body = translate( - `app.main.channel.system.${msg.content.type}`, - { user: users.get(msg.content.id)?.username } - ); - icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 }); - break; - case "channel_renamed": - body = translate( - `app.main.channel.system.channel_renamed`, - { user: users.get(msg.content.by)?.username, name: msg.content.name } - ); - icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 }); - break; - case "channel_description_changed": - case "channel_icon_changed": - body = translate( - `app.main.channel.system.${msg.content.type}`, - { user: users.get(msg.content.by)?.username } - ); - icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 }); - break; - } - } - - let notif = await createNotification(title, { - icon, - image, - body, - timestamp: decodeTime(msg._id), - tag: msg.channel, - badge: '/assets/icons/android-chrome-512x512.png', - silent: true - }); - - if (notif) { - notif.addEventListener("click", () => { - window.focus(); - const id = msg.channel; - if (id !== channel_id) { - let channel = client.channels.get(id); - if (channel) { - if (channel.channel_type === 'TextChannel') { - history.push(`/server/${channel.server}/channel/${id}`); - } else { - history.push(`/channel/${id}`); - } - } - } - }); - - notifications[msg.channel] = notif; - notif.addEventListener( - "close", - () => delete notifications[msg.channel] - ); - } - } - - async function relationship(user: User, property: string) { - if (client.user?.status?.presence === Users.Presence.Busy) return; - if (property !== "relationship") return; - if (!showNotification) return; - - let event; - switch (user.relationship) { - case Users.Relationship.Incoming: - event = translate("notifications.sent_request", { person: user.username }); - break; - case Users.Relationship.Friend: - event = translate("notifications.now_friends", { person: user.username }); - break; - default: - return; - } - - let notif = await createNotification(event, { - icon: client.users.getAvatarURL(user._id, { max_side: 256 }), - badge: '/assets/icons/android-chrome-512x512.png', - timestamp: +new Date() - }); - - notif?.addEventListener("click", () => { - history.push(`/friends`); - }); - } - - useEffect(() => { - client.addListener("message", message); - client.users.addListener("mutation", relationship); - - return () => { - client.removeListener("message", message); - client.users.removeListener("mutation", relationship); - }; - }, [client, playSound, guild_id, channel_id, showNotification, notifs]); - - useEffect(() => { - function visChange() { - if (document.visibilityState === "visible") { - if (notifications[channel_id]) { - notifications[channel_id].close(); - } - } - } - - visChange(); - - document.addEventListener("visibilitychange", visChange); - return () => - document.removeEventListener("visibilitychange", visChange); - }, [guild_id, channel_id]); - - return null; + const translate = useTranslation(); + const showNotification = options?.desktopEnabled ?? false; + + const client = useContext(AppContext); + const { guild: guild_id, channel: channel_id } = useParams<{ + guild: string; + channel: string; + }>(); + const history = useHistory(); + const playSound = useContext(SoundContext); + + async function message(msg: Message) { + if (msg.author === client.user!._id) return; + if (msg.channel === channel_id && document.hasFocus()) return; + if (client.user!.status?.presence === Users.Presence.Busy) return; + + const channel = client.channels.get(msg.channel); + const author = client.users.get(msg.author); + if (!channel) return; + if (author?.relationship === Users.Relationship.Blocked) return; + + const notifState = getNotificationState(notifs, channel); + if (!shouldNotify(notifState, msg, client.user!._id)) return; + + playSound("message"); + if (!showNotification) return; + + let title; + switch (channel.channel_type) { + case "SavedMessages": + return; + case "DirectMessage": + title = `@${author?.username}`; + break; + case "Group": + if (author?._id === SYSTEM_USER_ID) { + title = channel.name; + } else { + title = `@${author?.username} - ${channel.name}`; + } + break; + case "TextChannel": + const server = client.servers.get(channel.server); + title = `@${author?.username} (#${channel.name}, ${server?.name})`; + break; + default: + title = msg.channel; + break; + } + + let image; + if (msg.attachments) { + let imageAttachment = msg.attachments.find( + (x) => x.metadata.type === "Image", + ); + if (imageAttachment) { + image = client.generateFileURL(imageAttachment, { + max_side: 720, + }); + } + } + + let body, icon; + if (typeof msg.content === "string") { + body = client.markdownToText(msg.content); + icon = client.users.getAvatarURL(msg.author, { max_side: 256 }); + } else { + let users = client.users; + switch (msg.content.type) { + case "user_added": + case "user_remove": + body = translate( + `app.main.channel.system.${ + msg.content.type === "user_added" + ? "added_by" + : "removed_by" + }`, + { + user: users.get(msg.content.id)?.username, + other_user: users.get(msg.content.by)?.username, + }, + ); + icon = client.users.getAvatarURL(msg.content.id, { + max_side: 256, + }); + break; + case "user_joined": + case "user_left": + case "user_kicked": + case "user_banned": + body = translate( + `app.main.channel.system.${msg.content.type}`, + { user: users.get(msg.content.id)?.username }, + ); + icon = client.users.getAvatarURL(msg.content.id, { + max_side: 256, + }); + break; + case "channel_renamed": + body = translate( + `app.main.channel.system.channel_renamed`, + { + user: users.get(msg.content.by)?.username, + name: msg.content.name, + }, + ); + icon = client.users.getAvatarURL(msg.content.by, { + max_side: 256, + }); + break; + case "channel_description_changed": + case "channel_icon_changed": + body = translate( + `app.main.channel.system.${msg.content.type}`, + { user: users.get(msg.content.by)?.username }, + ); + icon = client.users.getAvatarURL(msg.content.by, { + max_side: 256, + }); + break; + } + } + + let notif = await createNotification(title, { + icon, + image, + body, + timestamp: decodeTime(msg._id), + tag: msg.channel, + badge: "/assets/icons/android-chrome-512x512.png", + silent: true, + }); + + if (notif) { + notif.addEventListener("click", () => { + window.focus(); + const id = msg.channel; + if (id !== channel_id) { + let channel = client.channels.get(id); + if (channel) { + if (channel.channel_type === "TextChannel") { + history.push( + `/server/${channel.server}/channel/${id}`, + ); + } else { + history.push(`/channel/${id}`); + } + } + } + }); + + notifications[msg.channel] = notif; + notif.addEventListener( + "close", + () => delete notifications[msg.channel], + ); + } + } + + async function relationship(user: User, property: string) { + if (client.user?.status?.presence === Users.Presence.Busy) return; + if (property !== "relationship") return; + if (!showNotification) return; + + let event; + switch (user.relationship) { + case Users.Relationship.Incoming: + event = translate("notifications.sent_request", { + person: user.username, + }); + break; + case Users.Relationship.Friend: + event = translate("notifications.now_friends", { + person: user.username, + }); + break; + default: + return; + } + + let notif = await createNotification(event, { + icon: client.users.getAvatarURL(user._id, { max_side: 256 }), + badge: "/assets/icons/android-chrome-512x512.png", + timestamp: +new Date(), + }); + + notif?.addEventListener("click", () => { + history.push(`/friends`); + }); + } + + useEffect(() => { + client.addListener("message", message); + client.users.addListener("mutation", relationship); + + return () => { + client.removeListener("message", message); + client.users.removeListener("mutation", relationship); + }; + }, [client, playSound, guild_id, channel_id, showNotification, notifs]); + + useEffect(() => { + function visChange() { + if (document.visibilityState === "visible") { + if (notifications[channel_id]) { + notifications[channel_id].close(); + } + } + } + + visChange(); + + document.addEventListener("visibilitychange", visChange); + return () => + document.removeEventListener("visibilitychange", visChange); + }, [guild_id, channel_id]); + + return null; } const NotifierComponent = connectState( - Notifier, - state => { - return { - options: state.settings.notification, - notifs: state.notifications - }; - }, - true + Notifier, + (state) => { + return { + options: state.settings.notification, + notifs: state.notifications, + }; + }, + true, ); -export default function Notifications() { - return ( - <Switch> - <Route path="/server/:server/channel/:channel"> - <NotifierComponent /> - </Route> - <Route path="/channel/:channel"> - <NotifierComponent /> - </Route> - <Route path="/"> - <NotifierComponent /> - </Route> - </Switch> - ); +export default function NotificationsComponent() { + return ( + <Switch> + <Route path="/server/:server/channel/:channel"> + <NotifierComponent /> + </Route> + <Route path="/channel/:channel"> + <NotifierComponent /> + </Route> + <Route path="/"> + <NotifierComponent /> + </Route> + </Switch> + ); } diff --git a/src/context/revoltjs/RequiresOnline.tsx b/src/context/revoltjs/RequiresOnline.tsx index ff2dd19998d6201e13acaf366bcb031d0864c479..a68ce45901e4a01efd5e56705de5bc52daa62ae6 100644 --- a/src/context/revoltjs/RequiresOnline.tsx +++ b/src/context/revoltjs/RequiresOnline.tsx @@ -1,44 +1,47 @@ -import { Text } from "preact-i18n"; +import { WifiOff } from "@styled-icons/boxicons-regular"; import styled from "styled-components"; + +import { Text } from "preact-i18n"; import { useContext } from "preact/hooks"; -import { Children } from "../../types/Preact"; -import { WifiOff } from "@styled-icons/boxicons-regular"; + import Preloader from "../../components/ui/Preloader"; + +import { Children } from "../../types/Preact"; import { ClientStatus, StatusContext } from "./RevoltClient"; interface Props { - children: Children; + children: Children; } const Base = styled.div` - gap: 16px; - padding: 1em; - display: flex; - user-select: none; - align-items: center; - flex-direction: row; - justify-content: center; - color: var(--tertiary-foreground); - background: var(--secondary-header); - - > div { - font-size: 18px; - } + gap: 16px; + padding: 1em; + display: flex; + user-select: none; + align-items: center; + flex-direction: row; + justify-content: center; + color: var(--tertiary-foreground); + background: var(--secondary-header); + + > div { + font-size: 18px; + } `; export default function RequiresOnline(props: Props) { - const status = useContext(StatusContext); - - if (status === ClientStatus.CONNECTING) return <Preloader type="ring" />; - if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY) - return ( - <Base> - <WifiOff size={16} /> - <div> - <Text id="app.special.requires_online" /> - </div> - </Base> - ); - - return <>{ props.children }</>; + const status = useContext(StatusContext); + + if (status === ClientStatus.CONNECTING) return <Preloader type="ring" />; + if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY) + return ( + <Base> + <WifiOff size={16} /> + <div> + <Text id="app.special.requires_online" /> + </div> + </Base> + ); + + return <>{props.children}</>; } diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx index 91dc602f0bdc2724b4b55fdf271b6a4fe4edc0d4..ddf67a19c29283762cd19b1d3f3a8c0fe57473b1 100644 --- a/src/context/revoltjs/RevoltClient.tsx +++ b/src/context/revoltjs/RevoltClient.tsx @@ -1,37 +1,42 @@ -import { openDB } from 'idb'; +import { openDB } from "idb"; +import { useHistory } from "react-router-dom"; import { Client } from "revolt.js"; -import { takeError } from "./util"; -import { createContext } from "preact"; -import { dispatch } from '../../redux'; -import { Children } from "../../types/Preact"; -import { useHistory } from 'react-router-dom'; import { Route } from "revolt.js/dist/api/routes"; + +import { createContext } from "preact"; +import { useEffect, useMemo, useState } from "preact/hooks"; + +import { SingletonMessageRenderer } from "../../lib/renderer/Singleton"; + +import { dispatch } from "../../redux"; import { connectState } from "../../redux/connector"; -import Preloader from "../../components/ui/Preloader"; import { AuthState } from "../../redux/reducers/auth"; -import { useEffect, useMemo, useState } from "preact/hooks"; -import { useIntermediate } from '../intermediate/Intermediate'; + +import Preloader from "../../components/ui/Preloader"; + +import { Children } from "../../types/Preact"; +import { useIntermediate } from "../intermediate/Intermediate"; import { registerEvents, setReconnectDisallowed } from "./events"; -import { SingletonMessageRenderer } from '../../lib/renderer/Singleton'; +import { takeError } from "./util"; export enum ClientStatus { - INIT, - LOADING, - READY, - OFFLINE, - DISCONNECTED, - CONNECTING, - RECONNECTING, - ONLINE, + INIT, + LOADING, + READY, + OFFLINE, + DISCONNECTED, + CONNECTING, + RECONNECTING, + ONLINE, } export interface ClientOperations { - login: (data: Route<"POST", "/auth/login">["data"]) => Promise<void>; - logout: (shouldRequest?: boolean) => Promise<void>; - loggedIn: () => boolean; - ready: () => boolean; + login: (data: Route<"POST", "/auth/login">["data"]) => Promise<void>; + logout: (shouldRequest?: boolean) => Promise<void>; + loggedIn: () => boolean; + ready: () => boolean; - openDM: (user_id: string) => Promise<string>; + openDM: (user_id: string) => Promise<string>; } // By the time they are used, they should all be initialized. @@ -42,189 +47,195 @@ export const StatusContext = createContext<ClientStatus>(null!); export const OperationsContext = createContext<ClientOperations>(null!); type Props = { - auth: AuthState; - children: Children; + auth: AuthState; + children: Children; }; function Context({ auth, children }: Props) { - const history = useHistory(); - const { openScreen } = useIntermediate(); - const [status, setStatus] = useState(ClientStatus.INIT); - const [client, setClient] = useState<Client>(undefined as unknown as Client); - - useEffect(() => { - (async () => { - let db; - try { - // Match sw.ts#L23 - db = await openDB('state', 3, { - upgrade(db) { - for (let store of [ "channels", "servers", "users", "members" ]) { - db.createObjectStore(store, { - keyPath: '_id' - }); - } - }, - }); - } catch (err) { - console.error('Failed to open IndexedDB store, continuing without.'); - } - - const client = new Client({ - autoReconnect: false, - apiURL: import.meta.env.VITE_API_URL, - debug: import.meta.env.DEV, - db - }); - - setClient(client); - SingletonMessageRenderer.subscribe(client); - setStatus(ClientStatus.LOADING); - })(); - }, [ ]); - - if (status === ClientStatus.INIT) return null; - - const operations: ClientOperations = useMemo(() => { - return { - login: async data => { - setReconnectDisallowed(true); - - try { - const onboarding = await client.login(data); - setReconnectDisallowed(false); - const login = () => - dispatch({ - type: "LOGIN", - session: client.session! // This [null assertion] is ok, we should have a session by now. - insert's words - }); - - if (onboarding) { - openScreen({ - id: "onboarding", - callback: (username: string) => - onboarding(username, true).then(login) - }); - } else { - login(); - } - } catch (err) { - setReconnectDisallowed(false); - throw err; - } - }, - logout: async shouldRequest => { - dispatch({ type: "LOGOUT" }); - - client.reset(); - dispatch({ type: "RESET" }); - - openScreen({ id: "none" }); - setStatus(ClientStatus.READY); - - client.websocket.disconnect(); - - if (shouldRequest) { - try { - await client.logout(); - } catch (err) { - console.error(err); - } - } - }, - loggedIn: () => typeof auth.active !== "undefined", - ready: () => ( - operations.loggedIn() && - typeof client.user !== "undefined" - ), - openDM: async (user_id: string) => { - let channel = await client.users.openDM(user_id); - history.push(`/channel/${channel!._id}`); - return channel!._id; - } - } - }, [ client, auth.active ]); - - useEffect(() => registerEvents({ operations }, setStatus, client), [ client ]); - - useEffect(() => { - (async () => { - if (client.db) { - await client.restore(); - } - - if (auth.active) { - dispatch({ type: "QUEUE_FAIL_ALL" }); - - const active = auth.accounts[auth.active]; - client.user = client.users.get(active.session.user_id); - if (!navigator.onLine) { - return setStatus(ClientStatus.OFFLINE); - } - - if (operations.ready()) - setStatus(ClientStatus.CONNECTING); - - if (navigator.onLine) { - await client - .fetchConfiguration() - .catch(() => - console.error("Failed to connect to API server.") - ); - } - - try { - await client.fetchConfiguration(); - const callback = await client.useExistingSession( - active.session - ); - - if (callback) { - openScreen({ id: "onboarding", callback }); - } - } catch (err) { - setStatus(ClientStatus.DISCONNECTED); - const error = takeError(err); - if (error === "Forbidden" || error === "Unauthorized") { - operations.logout(true); - openScreen({ id: "signed_out" }); - } else { - openScreen({ id: "error", error }); - } - } - } else { - try { - await client.fetchConfiguration() - } catch (err) { - console.error("Failed to connect to API server."); - } - - setStatus(ClientStatus.READY); - } - })(); - }, []); - - if (status === ClientStatus.LOADING) { - return <Preloader type="spinner" />; - } - - return ( - <AppContext.Provider value={client}> - <StatusContext.Provider value={status}> - <OperationsContext.Provider value={operations}> - { children } - </OperationsContext.Provider> - </StatusContext.Provider> - </AppContext.Provider> - ); + const history = useHistory(); + const { openScreen } = useIntermediate(); + const [status, setStatus] = useState(ClientStatus.INIT); + const [client, setClient] = useState<Client>( + undefined as unknown as Client, + ); + + useEffect(() => { + (async () => { + let db; + try { + // Match sw.ts#L23 + db = await openDB("state", 3, { + upgrade(db) { + for (let store of [ + "channels", + "servers", + "users", + "members", + ]) { + db.createObjectStore(store, { + keyPath: "_id", + }); + } + }, + }); + } catch (err) { + console.error( + "Failed to open IndexedDB store, continuing without.", + ); + } + + const client = new Client({ + autoReconnect: false, + apiURL: import.meta.env.VITE_API_URL, + debug: import.meta.env.DEV, + db, + }); + + setClient(client); + SingletonMessageRenderer.subscribe(client); + setStatus(ClientStatus.LOADING); + })(); + }, []); + + if (status === ClientStatus.INIT) return null; + + const operations: ClientOperations = useMemo(() => { + return { + login: async (data) => { + setReconnectDisallowed(true); + + try { + const onboarding = await client.login(data); + setReconnectDisallowed(false); + const login = () => + dispatch({ + type: "LOGIN", + session: client.session!, // This [null assertion] is ok, we should have a session by now. - insert's words + }); + + if (onboarding) { + openScreen({ + id: "onboarding", + callback: (username: string) => + onboarding(username, true).then(login), + }); + } else { + login(); + } + } catch (err) { + setReconnectDisallowed(false); + throw err; + } + }, + logout: async (shouldRequest) => { + dispatch({ type: "LOGOUT" }); + + client.reset(); + dispatch({ type: "RESET" }); + + openScreen({ id: "none" }); + setStatus(ClientStatus.READY); + + client.websocket.disconnect(); + + if (shouldRequest) { + try { + await client.logout(); + } catch (err) { + console.error(err); + } + } + }, + loggedIn: () => typeof auth.active !== "undefined", + ready: () => + operations.loggedIn() && typeof client.user !== "undefined", + openDM: async (user_id: string) => { + let channel = await client.users.openDM(user_id); + history.push(`/channel/${channel!._id}`); + return channel!._id; + }, + }; + }, [client, auth.active]); + + useEffect( + () => registerEvents({ operations }, setStatus, client), + [client], + ); + + useEffect(() => { + (async () => { + if (client.db) { + await client.restore(); + } + + if (auth.active) { + dispatch({ type: "QUEUE_FAIL_ALL" }); + + const active = auth.accounts[auth.active]; + client.user = client.users.get(active.session.user_id); + if (!navigator.onLine) { + return setStatus(ClientStatus.OFFLINE); + } + + if (operations.ready()) setStatus(ClientStatus.CONNECTING); + + if (navigator.onLine) { + await client + .fetchConfiguration() + .catch(() => + console.error("Failed to connect to API server."), + ); + } + + try { + await client.fetchConfiguration(); + const callback = await client.useExistingSession( + active.session, + ); + + if (callback) { + openScreen({ id: "onboarding", callback }); + } + } catch (err) { + setStatus(ClientStatus.DISCONNECTED); + const error = takeError(err); + if (error === "Forbidden" || error === "Unauthorized") { + operations.logout(true); + openScreen({ id: "signed_out" }); + } else { + openScreen({ id: "error", error }); + } + } + } else { + try { + await client.fetchConfiguration(); + } catch (err) { + console.error("Failed to connect to API server."); + } + + setStatus(ClientStatus.READY); + } + })(); + }, []); + + if (status === ClientStatus.LOADING) { + return <Preloader type="spinner" />; + } + + return ( + <AppContext.Provider value={client}> + <StatusContext.Provider value={status}> + <OperationsContext.Provider value={operations}> + {children} + </OperationsContext.Provider> + </StatusContext.Provider> + </AppContext.Provider> + ); } -export default connectState<{ children: Children }>( - Context, - state => { - return { - auth: state.auth, - sync: state.sync - }; - } -); +export default connectState<{ children: Children }>(Context, (state) => { + return { + auth: state.auth, + sync: state.sync, + }; +}); diff --git a/src/context/revoltjs/StateMonitor.tsx b/src/context/revoltjs/StateMonitor.tsx index cc0d7616196af52da5a2233eb48ddb8400c96164..f15d353aa3a548e379234428540e3f18ef5cac1a 100644 --- a/src/context/revoltjs/StateMonitor.tsx +++ b/src/context/revoltjs/StateMonitor.tsx @@ -1,77 +1,76 @@ /** * This file monitors the message cache to delete any queued messages that have already sent. */ - import { Message } from "revolt.js"; -import { AppContext } from "./RevoltClient"; -import { Typing } from "../../redux/reducers/typing"; + import { useContext, useEffect } from "preact/hooks"; + +import { dispatch } from "../../redux"; import { connectState } from "../../redux/connector"; import { QueuedMessage } from "../../redux/reducers/queue"; -import { dispatch } from "../../redux"; +import { Typing } from "../../redux/reducers/typing"; + +import { AppContext } from "./RevoltClient"; type Props = { - messages: QueuedMessage[]; - typing: Typing + messages: QueuedMessage[]; + typing: Typing; }; function StateMonitor(props: Props) { - const client = useContext(AppContext); + const client = useContext(AppContext); - useEffect(() => { - dispatch({ - type: 'QUEUE_DROP_ALL' - }); - }, [ ]); + useEffect(() => { + dispatch({ + type: "QUEUE_DROP_ALL", + }); + }, []); - useEffect(() => { - function add(msg: Message) { - if (!msg.nonce) return; - if (!props.messages.find(x => x.id === msg.nonce)) return; + useEffect(() => { + function add(msg: Message) { + if (!msg.nonce) return; + if (!props.messages.find((x) => x.id === msg.nonce)) return; - dispatch({ - type: 'QUEUE_REMOVE', - nonce: msg.nonce - }); - } + dispatch({ + type: "QUEUE_REMOVE", + nonce: msg.nonce, + }); + } - client.addListener('message', add); - return () => client.removeListener('message', add); - }, [ props.messages ]); + client.addListener("message", add); + return () => client.removeListener("message", add); + }, [props.messages]); - useEffect(() => { - function removeOld() { - if (!props.typing) return; - for (let channel of Object.keys(props.typing)) { - let users = props.typing[channel]; + useEffect(() => { + function removeOld() { + if (!props.typing) return; + for (let channel of Object.keys(props.typing)) { + let users = props.typing[channel]; - for (let user of users) { - if (+ new Date() > user.started + 5000) { - dispatch({ - type: 'TYPING_STOP', - channel, - user: user.id - }); - } - } - } - } + for (let user of users) { + if (+new Date() > user.started + 5000) { + dispatch({ + type: "TYPING_STOP", + channel, + user: user.id, + }); + } + } + } + } - removeOld(); + removeOld(); - let interval = setInterval(removeOld, 1000); - return () => clearInterval(interval); - }, [ props.typing ]); + let interval = setInterval(removeOld, 1000); + return () => clearInterval(interval); + }, [props.typing]); - return null; + return null; } -export default connectState( - StateMonitor, - state => { - return { - messages: [...state.queue], - typing: state.typing - }; - } -); +export default connectState(StateMonitor, (state) => { + return { + messages: [...state.queue], + typing: state.typing, + }; +}); diff --git a/src/context/revoltjs/SyncManager.tsx b/src/context/revoltjs/SyncManager.tsx index f09ffb0d1ca7477e4c715085267f541b85f680e5..ee24d525c1179634dd21dc146ad4be7b864419fc 100644 --- a/src/context/revoltjs/SyncManager.tsx +++ b/src/context/revoltjs/SyncManager.tsx @@ -1,126 +1,146 @@ /** * This file monitors changes to settings and syncs them to the server. */ - import isEqual from "lodash.isequal"; -import { Language } from "../Locale"; import { Sync } from "revolt.js/dist/api/objects"; +import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; + import { useContext, useEffect } from "preact/hooks"; + +import { dispatch } from "../../redux"; import { connectState } from "../../redux/connector"; -import { Settings } from "../../redux/reducers/settings"; import { Notifications } from "../../redux/reducers/notifications"; +import { Settings } from "../../redux/reducers/settings"; +import { + DEFAULT_ENABLED_SYNC, + SyncData, + SyncKeys, + SyncOptions, +} from "../../redux/reducers/sync"; + +import { Language } from "../Locale"; import { AppContext, ClientStatus, StatusContext } from "./RevoltClient"; -import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; -import { DEFAULT_ENABLED_SYNC, SyncData, SyncKeys, SyncOptions } from "../../redux/reducers/sync"; -import { dispatch } from "../../redux"; type Props = { - settings: Settings, - locale: Language, - sync: SyncOptions, - notifications: Notifications + settings: Settings; + locale: Language; + sync: SyncOptions; + notifications: Notifications; }; -var lastValues: { [key in SyncKeys]?: any } = { }; - -export function mapSync(packet: Sync.UserSettings, revision?: Record<string, number>) { - let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = {}; - for (let key of Object.keys(packet)) { - let [ timestamp, obj ] = packet[key]; - if (timestamp < (revision ?? {})[key] ?? 0) { - continue; - } - - let object; - if (obj[0] === '{') { - object = JSON.parse(obj) - } else { - object = obj; - } - - lastValues[key as SyncKeys] = object; - update[key as SyncKeys] = [ timestamp, object ]; - } - - return update; +var lastValues: { [key in SyncKeys]?: any } = {}; + +export function mapSync( + packet: Sync.UserSettings, + revision?: Record<string, number>, +) { + let update: { [key in SyncKeys]?: [number, SyncData[key]] } = {}; + for (let key of Object.keys(packet)) { + let [timestamp, obj] = packet[key]; + if (timestamp < (revision ?? {})[key] ?? 0) { + continue; + } + + let object; + if (obj[0] === "{") { + object = JSON.parse(obj); + } else { + object = obj; + } + + lastValues[key as SyncKeys] = object; + update[key as SyncKeys] = [timestamp, object]; + } + + return update; } function SyncManager(props: Props) { - const client = useContext(AppContext); - const status = useContext(StatusContext); - - useEffect(() => { - if (status === ClientStatus.ONLINE) { - client - .syncFetchSettings(DEFAULT_ENABLED_SYNC.filter(x => !props.sync?.disabled?.includes(x))) - .then(data => { - dispatch({ - type: 'SYNC_UPDATE', - update: mapSync(data) - }); - }); - - client - .syncFetchUnreads() - .then(unreads => dispatch({ type: 'UNREADS_SET', unreads })); - } - }, [ status ]); - - function syncChange(key: SyncKeys, data: any) { - let timestamp = + new Date(); - dispatch({ - type: 'SYNC_SET_REVISION', - key, - timestamp - }); - - client.syncSetSettings({ - [key]: data - }, timestamp); - } - - let disabled = props.sync.disabled ?? []; - for (let [key, object] of [ ['appearance', props.settings.appearance], ['theme', props.settings.theme], ['locale', props.locale], ['notifications', props.notifications] ] as [SyncKeys, any][]) { - useEffect(() => { - if (disabled.indexOf(key) === -1) { - if (typeof lastValues[key] !== 'undefined') { - if (!isEqual(lastValues[key], object)) { - syncChange(key, object); - } - } - } - - lastValues[key] = object; - }, [ disabled, object ]); - } - - useEffect(() => { - function onPacket(packet: ClientboundNotification) { - if (packet.type === 'UserSettingsUpdate') { - let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = mapSync(packet.update, props.sync.revision); - - dispatch({ - type: 'SYNC_UPDATE', - update - }); - } - } - - client.addListener('packet', onPacket); - return () => client.removeListener('packet', onPacket); - }, [ disabled, props.sync ]); - - return null; + const client = useContext(AppContext); + const status = useContext(StatusContext); + + useEffect(() => { + if (status === ClientStatus.ONLINE) { + client + .syncFetchSettings( + DEFAULT_ENABLED_SYNC.filter( + (x) => !props.sync?.disabled?.includes(x), + ), + ) + .then((data) => { + dispatch({ + type: "SYNC_UPDATE", + update: mapSync(data), + }); + }); + + client + .syncFetchUnreads() + .then((unreads) => dispatch({ type: "UNREADS_SET", unreads })); + } + }, [status]); + + function syncChange(key: SyncKeys, data: any) { + let timestamp = +new Date(); + dispatch({ + type: "SYNC_SET_REVISION", + key, + timestamp, + }); + + client.syncSetSettings( + { + [key]: data, + }, + timestamp, + ); + } + + let disabled = props.sync.disabled ?? []; + for (let [key, object] of [ + ["appearance", props.settings.appearance], + ["theme", props.settings.theme], + ["locale", props.locale], + ["notifications", props.notifications], + ] as [SyncKeys, any][]) { + useEffect(() => { + if (disabled.indexOf(key) === -1) { + if (typeof lastValues[key] !== "undefined") { + if (!isEqual(lastValues[key], object)) { + syncChange(key, object); + } + } + } + + lastValues[key] = object; + }, [disabled, object]); + } + + useEffect(() => { + function onPacket(packet: ClientboundNotification) { + if (packet.type === "UserSettingsUpdate") { + let update: { [key in SyncKeys]?: [number, SyncData[key]] } = + mapSync(packet.update, props.sync.revision); + + dispatch({ + type: "SYNC_UPDATE", + update, + }); + } + } + + client.addListener("packet", onPacket); + return () => client.removeListener("packet", onPacket); + }, [disabled, props.sync]); + + return null; } -export default connectState( - SyncManager, - state => { - return { - settings: state.settings, - locale: state.locale, - sync: state.sync, - notifications: state.notifications - }; - } -); +export default connectState(SyncManager, (state) => { + return { + settings: state.settings, + locale: state.locale, + sync: state.sync, + notifications: state.notifications, + }; +}); diff --git a/src/context/revoltjs/events.ts b/src/context/revoltjs/events.ts index 319894689955981271fff384005383b3855eee60..b603f0876366eec12b132f34c96011319262ac38 100644 --- a/src/context/revoltjs/events.ts +++ b/src/context/revoltjs/events.ts @@ -1,148 +1,155 @@ -import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; import { Client, Message } from "revolt.js/dist"; -import { - ClientOperations, - ClientStatus -} from "./RevoltClient"; +import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; + import { StateUpdater } from "preact/hooks"; + import { dispatch } from "../../redux"; +import { ClientOperations, ClientStatus } from "./RevoltClient"; + export var preventReconnect = false; let preventUntil = 0; export function setReconnectDisallowed(allowed: boolean) { - preventReconnect = allowed; + preventReconnect = allowed; } -export function registerEvents({ - operations -}: { operations: ClientOperations }, setStatus: StateUpdater<ClientStatus>, client: Client) { - function attemptReconnect() { - if (preventReconnect) return; - function reconnect() { - preventUntil = +new Date() + 2000; - client.websocket.connect().catch(err => console.error(err)); - } - - if (+new Date() > preventUntil) { - setTimeout(reconnect, 2000); - } else { - reconnect(); - } - } - - let listeners: Record<string, (...args: any[]) => void> = { - connecting: () => - operations.ready() && setStatus(ClientStatus.CONNECTING), - - dropped: () => { - if (operations.ready()) { - setStatus(ClientStatus.DISCONNECTED); - attemptReconnect(); - } - }, - - packet: (packet: ClientboundNotification) => { - switch (packet.type) { - case "ChannelStartTyping": { - if (packet.user === client.user?._id) return; - dispatch({ - type: "TYPING_START", - channel: packet.id, - user: packet.user - }); - break; - } - case "ChannelStopTyping": { - if (packet.user === client.user?._id) return; - dispatch({ - type: "TYPING_STOP", - channel: packet.id, - user: packet.user - }); - break; - } - case "ChannelAck": { - dispatch({ - type: "UNREADS_MARK_READ", - channel: packet.id, - message: packet.message_id - }); - break; - } - } - }, - - message: (message: Message) => { - if (message.mentions?.includes(client.user!._id)) { - dispatch({ - type: "UNREADS_MENTION", - channel: message.channel, - message: message._id - }); - } - }, - - ready: () => setStatus(ClientStatus.ONLINE) - }; - - if (import.meta.env.DEV) { - listeners = new Proxy(listeners, { - get: (target, listener, receiver) => (...args: unknown[]) => { - console.debug(`Calling ${listener.toString()} with`, args); - Reflect.get(target, listener)(...args) - } - }) - } - - // TODO: clean this a bit and properly handle types - for (const listener in listeners) { - client.addListener(listener, listeners[listener]); - } - - function logMutation(target: string, key: string) { - console.log('(o) Object mutated', target, '\nChanged:', key); - } - - if (import.meta.env.DEV) { - client.users.addListener('mutation', logMutation); - client.servers.addListener('mutation', logMutation); - client.channels.addListener('mutation', logMutation); - client.servers.members.addListener('mutation', logMutation); - } - - const online = () => { - if (operations.ready()) { - setStatus(ClientStatus.RECONNECTING); - setReconnectDisallowed(false); - attemptReconnect(); - } - }; - - const offline = () => { - if (operations.ready()) { - setReconnectDisallowed(true); - client.websocket.disconnect(); - setStatus(ClientStatus.OFFLINE); - } - }; - - window.addEventListener("online", online); - window.addEventListener("offline", offline); - - return () => { - for (const listener in listeners) { - client.removeListener(listener, listeners[listener as keyof typeof listeners]); - } - - if (import.meta.env.DEV) { - client.users.removeListener('mutation', logMutation); - client.servers.removeListener('mutation', logMutation); - client.channels.removeListener('mutation', logMutation); - client.servers.members.removeListener('mutation', logMutation); - } - - window.removeEventListener("online", online); - window.removeEventListener("offline", offline); - }; +export function registerEvents( + { operations }: { operations: ClientOperations }, + setStatus: StateUpdater<ClientStatus>, + client: Client, +) { + function attemptReconnect() { + if (preventReconnect) return; + function reconnect() { + preventUntil = +new Date() + 2000; + client.websocket.connect().catch((err) => console.error(err)); + } + + if (+new Date() > preventUntil) { + setTimeout(reconnect, 2000); + } else { + reconnect(); + } + } + + let listeners: Record<string, (...args: any[]) => void> = { + connecting: () => + operations.ready() && setStatus(ClientStatus.CONNECTING), + + dropped: () => { + if (operations.ready()) { + setStatus(ClientStatus.DISCONNECTED); + attemptReconnect(); + } + }, + + packet: (packet: ClientboundNotification) => { + switch (packet.type) { + case "ChannelStartTyping": { + if (packet.user === client.user?._id) return; + dispatch({ + type: "TYPING_START", + channel: packet.id, + user: packet.user, + }); + break; + } + case "ChannelStopTyping": { + if (packet.user === client.user?._id) return; + dispatch({ + type: "TYPING_STOP", + channel: packet.id, + user: packet.user, + }); + break; + } + case "ChannelAck": { + dispatch({ + type: "UNREADS_MARK_READ", + channel: packet.id, + message: packet.message_id, + }); + break; + } + } + }, + + message: (message: Message) => { + if (message.mentions?.includes(client.user!._id)) { + dispatch({ + type: "UNREADS_MENTION", + channel: message.channel, + message: message._id, + }); + } + }, + + ready: () => setStatus(ClientStatus.ONLINE), + }; + + if (import.meta.env.DEV) { + listeners = new Proxy(listeners, { + get: + (target, listener, receiver) => + (...args: unknown[]) => { + console.debug(`Calling ${listener.toString()} with`, args); + Reflect.get(target, listener)(...args); + }, + }); + } + + // TODO: clean this a bit and properly handle types + for (const listener in listeners) { + client.addListener(listener, listeners[listener]); + } + + function logMutation(target: string, key: string) { + console.log("(o) Object mutated", target, "\nChanged:", key); + } + + if (import.meta.env.DEV) { + client.users.addListener("mutation", logMutation); + client.servers.addListener("mutation", logMutation); + client.channels.addListener("mutation", logMutation); + client.servers.members.addListener("mutation", logMutation); + } + + const online = () => { + if (operations.ready()) { + setStatus(ClientStatus.RECONNECTING); + setReconnectDisallowed(false); + attemptReconnect(); + } + }; + + const offline = () => { + if (operations.ready()) { + setReconnectDisallowed(true); + client.websocket.disconnect(); + setStatus(ClientStatus.OFFLINE); + } + }; + + window.addEventListener("online", online); + window.addEventListener("offline", offline); + + return () => { + for (const listener in listeners) { + client.removeListener( + listener, + listeners[listener as keyof typeof listeners], + ); + } + + if (import.meta.env.DEV) { + client.users.removeListener("mutation", logMutation); + client.servers.removeListener("mutation", logMutation); + client.channels.removeListener("mutation", logMutation); + client.servers.members.removeListener("mutation", logMutation); + } + + window.removeEventListener("online", online); + window.removeEventListener("offline", offline); + }; } diff --git a/src/context/revoltjs/hooks.ts b/src/context/revoltjs/hooks.ts index fac889862a8075df4e1e85707062b9e09d06a172..ffab5c9324a9f30f96a612980af8c8aee945fef0 100644 --- a/src/context/revoltjs/hooks.ts +++ b/src/context/revoltjs/hooks.ts @@ -1,177 +1,232 @@ -import { useCallback, useContext, useEffect, useState } from "preact/hooks"; +import { Client, PermissionCalculator } from "revolt.js"; import { Channels, Servers, Users } from "revolt.js/dist/api/objects"; -import { Client, PermissionCalculator } from 'revolt.js'; -import { AppContext } from "./RevoltClient"; import Collection from "revolt.js/dist/maps/Collection"; +import { useCallback, useContext, useEffect, useState } from "preact/hooks"; + +import { AppContext } from "./RevoltClient"; + export interface HookContext { - client: Client, - forceUpdate: () => void + client: Client; + forceUpdate: () => void; } export function useForceUpdate(context?: HookContext): HookContext { - const client = useContext(AppContext); - if (context) return context; - - const H = useState(0); - var updateState: (_: number) => void; - if (Array.isArray(H)) { - let [, u] = H; - updateState = u; - } else { - console.warn('Failed to construct using useState.'); - updateState = ()=>{}; - } - - return { client, forceUpdate: () => updateState(Math.random()) }; + const client = useContext(AppContext); + if (context) return context; + + const H = useState(0); + var updateState: (_: number) => void; + if (Array.isArray(H)) { + let [, u] = H; + updateState = u; + } else { + console.warn("Failed to construct using useState."); + updateState = () => {}; + } + + return { client, forceUpdate: () => updateState(Math.random()) }; } // TODO: utils.d.ts maybe? -type PickProperties<T, U> = Pick<T, { - [K in keyof T]: T[K] extends U ? K : never -}[keyof T]> +type PickProperties<T, U> = Pick< + T, + { + [K in keyof T]: T[K] extends U ? K : never; + }[keyof T] +>; // The keys in Client that are an object // for some reason undefined keeps appearing despite there being no reason to so it's filtered out -type ClientCollectionKey = Exclude<keyof PickProperties<Client, Collection<any>>, undefined>; - -function useObject(type: ClientCollectionKey, id?: string | string[], context?: HookContext) { - const ctx = useForceUpdate(context); - - function update(target: any) { - if (typeof id === 'string' ? target === id : - Array.isArray(id) ? id.includes(target) : true) { - ctx.forceUpdate(); - } - } - - const map = ctx.client[type]; - useEffect(() => { - map.addListener("update", update); - return () => map.removeListener("update", update); - }, [id]); - - return typeof id === 'string' ? map.get(id) - : Array.isArray(id) ? id.map(x => map.get(x)) - : map.toArray(); +type ClientCollectionKey = Exclude< + keyof PickProperties<Client, Collection<any>>, + undefined +>; + +function useObject( + type: ClientCollectionKey, + id?: string | string[], + context?: HookContext, +) { + const ctx = useForceUpdate(context); + + function update(target: any) { + if ( + typeof id === "string" + ? target === id + : Array.isArray(id) + ? id.includes(target) + : true + ) { + ctx.forceUpdate(); + } + } + + const map = ctx.client[type]; + useEffect(() => { + map.addListener("update", update); + return () => map.removeListener("update", update); + }, [id]); + + return typeof id === "string" + ? map.get(id) + : Array.isArray(id) + ? id.map((x) => map.get(x)) + : map.toArray(); } export function useUser(id?: string, context?: HookContext) { - if (typeof id === "undefined") return; - return useObject('users', id, context) as Readonly<Users.User> | undefined; + if (typeof id === "undefined") return; + return useObject("users", id, context) as Readonly<Users.User> | undefined; } export function useSelf(context?: HookContext) { - const ctx = useForceUpdate(context); - return useUser(ctx.client.user!._id, ctx); + const ctx = useForceUpdate(context); + return useUser(ctx.client.user!._id, ctx); } export function useUsers(ids?: string[], context?: HookContext) { - return useObject('users', ids, context) as (Readonly<Users.User> | undefined)[]; + return useObject("users", ids, context) as ( + | Readonly<Users.User> + | undefined + )[]; } export function useChannel(id?: string, context?: HookContext) { - if (typeof id === "undefined") return; - return useObject('channels', id, context) as Readonly<Channels.Channel> | undefined; + if (typeof id === "undefined") return; + return useObject("channels", id, context) as + | Readonly<Channels.Channel> + | undefined; } export function useChannels(ids?: string[], context?: HookContext) { - return useObject('channels', ids, context) as (Readonly<Channels.Channel> | undefined)[]; + return useObject("channels", ids, context) as ( + | Readonly<Channels.Channel> + | undefined + )[]; } export function useServer(id?: string, context?: HookContext) { - if (typeof id === "undefined") return; - return useObject('servers', id, context) as Readonly<Servers.Server> | undefined; + if (typeof id === "undefined") return; + return useObject("servers", id, context) as + | Readonly<Servers.Server> + | undefined; } export function useServers(ids?: string[], context?: HookContext) { - return useObject('servers', ids, context) as (Readonly<Servers.Server> | undefined)[]; + return useObject("servers", ids, context) as ( + | Readonly<Servers.Server> + | undefined + )[]; } export function useDMs(context?: HookContext) { - const ctx = useForceUpdate(context); - - function mutation(target: string) { - let channel = ctx.client.channels.get(target); - if (channel) { - if (channel.channel_type === 'DirectMessage' || channel.channel_type === 'Group') { - ctx.forceUpdate(); - } - } - } - - const map = ctx.client.channels; - useEffect(() => { - map.addListener("update", mutation); - return () => map.removeListener("update", mutation); - }, []); - - return map - .toArray() - .filter(x => x.channel_type === 'DirectMessage' || x.channel_type === 'Group' || x.channel_type === 'SavedMessages') as (Channels.GroupChannel | Channels.DirectMessageChannel | Channels.SavedMessagesChannel)[]; + const ctx = useForceUpdate(context); + + function mutation(target: string) { + let channel = ctx.client.channels.get(target); + if (channel) { + if ( + channel.channel_type === "DirectMessage" || + channel.channel_type === "Group" + ) { + ctx.forceUpdate(); + } + } + } + + const map = ctx.client.channels; + useEffect(() => { + map.addListener("update", mutation); + return () => map.removeListener("update", mutation); + }, []); + + return map + .toArray() + .filter( + (x) => + x.channel_type === "DirectMessage" || + x.channel_type === "Group" || + x.channel_type === "SavedMessages", + ) as ( + | Channels.GroupChannel + | Channels.DirectMessageChannel + | Channels.SavedMessagesChannel + )[]; } export function useUserPermission(id: string, context?: HookContext) { - const ctx = useForceUpdate(context); - - const mutation = (target: string) => (target === id) && ctx.forceUpdate(); - useEffect(() => { - ctx.client.users.addListener("update", mutation); - return () => ctx.client.users.removeListener("update", mutation); - }, [id]); - - let calculator = new PermissionCalculator(ctx.client); - return calculator.forUser(id); + const ctx = useForceUpdate(context); + + const mutation = (target: string) => target === id && ctx.forceUpdate(); + useEffect(() => { + ctx.client.users.addListener("update", mutation); + return () => ctx.client.users.removeListener("update", mutation); + }, [id]); + + let calculator = new PermissionCalculator(ctx.client); + return calculator.forUser(id); } export function useChannelPermission(id: string, context?: HookContext) { - const ctx = useForceUpdate(context); - - const channel = ctx.client.channels.get(id); - const server = (channel && (channel.channel_type === 'TextChannel' || channel.channel_type === 'VoiceChannel')) ? channel.server : undefined; - - const mutation = (target: string) => (target === id) && ctx.forceUpdate(); - const mutationServer = (target: string) => (target === server) && ctx.forceUpdate(); - const mutationMember = (target: string) => (target.substr(26) === ctx.client.user!._id) && ctx.forceUpdate(); - - useEffect(() => { - ctx.client.channels.addListener("update", mutation); - - if (server) { - ctx.client.servers.addListener("update", mutationServer); - ctx.client.servers.members.addListener("update", mutationMember); - } - - return () => { - ctx.client.channels.removeListener("update", mutation); - - if (server) { - ctx.client.servers.removeListener("update", mutationServer); - ctx.client.servers.members.removeListener("update", mutationMember); - } - } - }, [id]); - - let calculator = new PermissionCalculator(ctx.client); - return calculator.forChannel(id); + const ctx = useForceUpdate(context); + + const channel = ctx.client.channels.get(id); + const server = + channel && + (channel.channel_type === "TextChannel" || + channel.channel_type === "VoiceChannel") + ? channel.server + : undefined; + + const mutation = (target: string) => target === id && ctx.forceUpdate(); + const mutationServer = (target: string) => + target === server && ctx.forceUpdate(); + const mutationMember = (target: string) => + target.substr(26) === ctx.client.user!._id && ctx.forceUpdate(); + + useEffect(() => { + ctx.client.channels.addListener("update", mutation); + + if (server) { + ctx.client.servers.addListener("update", mutationServer); + ctx.client.servers.members.addListener("update", mutationMember); + } + + return () => { + ctx.client.channels.removeListener("update", mutation); + + if (server) { + ctx.client.servers.removeListener("update", mutationServer); + ctx.client.servers.members.removeListener( + "update", + mutationMember, + ); + } + }; + }, [id]); + + let calculator = new PermissionCalculator(ctx.client); + return calculator.forChannel(id); } export function useServerPermission(id: string, context?: HookContext) { - const ctx = useForceUpdate(context); - - const mutation = (target: string) => (target === id) && ctx.forceUpdate(); - const mutationMember = (target: string) => (target.substr(26) === ctx.client.user!._id) && ctx.forceUpdate(); - - useEffect(() => { - ctx.client.servers.addListener("update", mutation); - ctx.client.servers.members.addListener("update", mutationMember); - - return () => { - ctx.client.servers.removeListener("update", mutation); - ctx.client.servers.members.removeListener("update", mutationMember); - } - }, [id]); - - let calculator = new PermissionCalculator(ctx.client); - return calculator.forServer(id); + const ctx = useForceUpdate(context); + + const mutation = (target: string) => target === id && ctx.forceUpdate(); + const mutationMember = (target: string) => + target.substr(26) === ctx.client.user!._id && ctx.forceUpdate(); + + useEffect(() => { + ctx.client.servers.addListener("update", mutation); + ctx.client.servers.members.addListener("update", mutationMember); + + return () => { + ctx.client.servers.removeListener("update", mutation); + ctx.client.servers.members.removeListener("update", mutationMember); + }; + }, [id]); + + let calculator = new PermissionCalculator(ctx.client); + return calculator.forServer(id); } diff --git a/src/context/revoltjs/util.tsx b/src/context/revoltjs/util.tsx index a611d3e1eac8d3c80cbc80e8cfbb2e249fb05605..e575c71119220b27ff956e1379cc1f1f77a143d9 100644 --- a/src/context/revoltjs/util.tsx +++ b/src/context/revoltjs/util.tsx @@ -1,48 +1,57 @@ -import { Channel, Message, User } from "revolt.js/dist/api/objects"; -import { Children } from "../../types/Preact"; -import { Text } from "preact-i18n"; import { Client } from "revolt.js"; +import { Channel, Message, User } from "revolt.js/dist/api/objects"; -export function takeError( - error: any -): string { - const type = error?.response?.data?.type; - let id = type; - if (!type) { - if (error?.response?.status === 403) { - return "Unauthorized"; - } else if (error && (!!error.isAxiosError && !error.response)) { - return "NetworkError"; - } - - console.error(error); - return "UnknownError"; - } - - return id; -} - -export function getChannelName(client: Client, channel: Channel, prefixType?: boolean): Children { - if (channel.channel_type === "SavedMessages") - return <Text id="app.navigation.tabs.saved" />; +import { Text } from "preact-i18n"; - if (channel.channel_type === "DirectMessage") { - let uid = client.channels.getRecipient(channel._id); - return <>{prefixType && "@"}{client.users.get(uid)?.username}</>; - } +import { Children } from "../../types/Preact"; - if (channel.channel_type === "TextChannel" && prefixType) { - return <>#{channel.name}</>; - } +export function takeError(error: any): string { + const type = error?.response?.data?.type; + let id = type; + if (!type) { + if (error?.response?.status === 403) { + return "Unauthorized"; + } else if (error && !!error.isAxiosError && !error.response) { + return "NetworkError"; + } + + console.error(error); + return "UnknownError"; + } + + return id; +} - return <>{channel.name}</>; +export function getChannelName( + client: Client, + channel: Channel, + prefixType?: boolean, +): Children { + if (channel.channel_type === "SavedMessages") + return <Text id="app.navigation.tabs.saved" />; + + if (channel.channel_type === "DirectMessage") { + let uid = client.channels.getRecipient(channel._id); + return ( + <> + {prefixType && "@"} + {client.users.get(uid)?.username} + </> + ); + } + + if (channel.channel_type === "TextChannel" && prefixType) { + return <>#{channel.name}</>; + } + + return <>{channel.name}</>; } export type MessageObject = Omit<Message, "edited"> & { edited?: string }; export function mapMessage(message: Partial<Message>) { - const { edited, ...msg } = message; - return { - ...msg, - edited: edited?.$date, - } as MessageObject; + const { edited, ...msg } = message; + return { + ...msg, + edited: edited?.$date, + } as MessageObject; } diff --git a/src/env.d.ts b/src/env.d.ts index 2f894b5f8f587a23f2535639178142b8501fb040..3b2063f055be1ba7b31517802335b778dc87a590 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,5 +1,4 @@ interface ImportMetaEnv { - VITE_API_URL: string; - VITE_THEMES_URL: string; + VITE_API_URL: string; + VITE_THEMES_URL: string; } - \ No newline at end of file diff --git a/src/lib/ConditionalLink.tsx b/src/lib/ConditionalLink.tsx index b739219dcd380a3e7a47e1862841bcef5b7e6d9e..53f89551ff8a48b476e868a8e07c6386d6c5bbce 100644 --- a/src/lib/ConditionalLink.tsx +++ b/src/lib/ConditionalLink.tsx @@ -1,15 +1,16 @@ import { Link, LinkProps } from "react-router-dom"; -type Props = LinkProps & JSX.HTMLAttributes<HTMLAnchorElement> & { - active: boolean -}; +type Props = LinkProps & + JSX.HTMLAttributes<HTMLAnchorElement> & { + active: boolean; + }; export default function ConditionalLink(props: Props) { - const { active, ...linkProps } = props; + const { active, ...linkProps } = props; - if (active) { - return <a>{ props.children }</a>; - } else { - return <Link {...linkProps} />; - } + if (active) { + return <a>{props.children}</a>; + } else { + return <Link {...linkProps} />; + } } diff --git a/src/lib/ContextMenus.tsx b/src/lib/ContextMenus.tsx index 89f1a6bb87f906c6cde55a35b4ab5b7590a13251..8f73e27141a834a37ac028eef80047e6b2598547 100644 --- a/src/lib/ContextMenus.tsx +++ b/src/lib/ContextMenus.tsx @@ -1,764 +1,1018 @@ -import { Text } from "preact-i18n"; -import { useContext } from "preact/hooks"; +import { + At, + Bell, + BellOff, + Check, + CheckSquare, + ChevronRight, + Block, + Square, + LeftArrowAlt, + Trash, +} from "@styled-icons/boxicons-regular"; +import { Cog } from "@styled-icons/boxicons-solid"; import { useHistory } from "react-router-dom"; -import { Attachment, Channels, Message, Servers, Users } from "revolt.js/dist/api/objects"; import { - ContextMenu, - ContextMenuWithData, - MenuItem, - openContextMenu + Attachment, + Channels, + Message, + Servers, + Users, +} from "revolt.js/dist/api/objects"; +import { + ChannelPermission, + ServerPermission, + UserPermission, +} from "revolt.js/dist/api/permissions"; + +import { + ContextMenu, + ContextMenuWithData, + MenuItem, + openContextMenu, } from "preact-context-menu"; -import { ChannelPermission, ServerPermission, UserPermission } from "revolt.js/dist/api/permissions"; +import { Text } from "preact-i18n"; +import { useContext } from "preact/hooks"; + +import { dispatch } from "../redux"; +import { connectState } from "../redux/connector"; +import { + getNotificationState, + Notifications, + NotificationState, +} from "../redux/reducers/notifications"; import { QueuedMessage } from "../redux/reducers/queue"; + import { useIntermediate } from "../context/intermediate/Intermediate"; -import { AppContext, ClientStatus, StatusContext } from "../context/revoltjs/RevoltClient"; +import { + AppContext, + ClientStatus, + StatusContext, +} from "../context/revoltjs/RevoltClient"; +import { + useChannel, + useChannelPermission, + useForceUpdate, + useServer, + useServerPermission, + useUser, + useUserPermission, +} from "../context/revoltjs/hooks"; import { takeError } from "../context/revoltjs/util"; -import { useChannel, useChannelPermission, useForceUpdate, useServer, useServerPermission, useUser, useUserPermission } from "../context/revoltjs/hooks"; -import { Children } from "../types/Preact"; -import LineDivider from "../components/ui/LineDivider"; -import { connectState } from "../redux/connector"; -import { internalEmit } from "./eventEmitter"; -import { At, Bell, BellOff, Check, CheckSquare, ChevronRight, Block, Square, LeftArrowAlt, Trash } from "@styled-icons/boxicons-regular"; -import { Cog } from "@styled-icons/boxicons-solid"; -import { getNotificationState, Notifications, NotificationState } from "../redux/reducers/notifications"; + import UserStatus from "../components/common/user/UserStatus"; import IconButton from "../components/ui/IconButton"; -import { dispatch } from "../redux"; +import LineDivider from "../components/ui/LineDivider"; + +import { Children } from "../types/Preact"; +import { internalEmit } from "./eventEmitter"; interface ContextMenuData { - user?: string; - server?: string; - server_list?: string; - channel?: string; - message?: Message; - - unread?: boolean; - queued?: QueuedMessage; - contextualChannel?: string; + user?: string; + server?: string; + server_list?: string; + channel?: string; + message?: Message; + + unread?: boolean; + queued?: QueuedMessage; + contextualChannel?: string; } type Action = - | { action: "copy_id"; id: string } - | { action: "copy_selection" } - | { action: "copy_text"; content: string } - | { action: "mark_as_read"; channel: Channels.Channel } - | { action: "retry_message"; message: QueuedMessage } - | { action: "cancel_message"; message: QueuedMessage } - | { action: "mention"; user: string } - | { action: "reply_message"; id: string } - | { action: "quote_message"; content: string } - | { action: "edit_message"; id: string } - | { action: "delete_message"; target: Channels.Message } - | { action: "open_file"; attachment: Attachment } - | { action: "save_file"; attachment: Attachment } - | { action: "copy_file_link"; attachment: Attachment } - | { action: "open_link"; link: string } - | { action: "copy_link"; link: string } - | { action: "remove_member"; channel: string; user: string } - | { action: "kick_member"; target: Servers.Server; user: string } - | { action: "ban_member"; target: Servers.Server; user: string } - | { action: "view_profile"; user: string } - | { action: "message_user"; user: string } - | { action: "block_user"; user: Users.User } - | { action: "unblock_user"; user: Users.User } - | { action: "add_friend"; user: Users.User } - | { action: "remove_friend"; user: Users.User } - | { action: "cancel_friend"; user: Users.User } - | { action: "set_presence"; presence: Users.Presence } - | { action: "set_status" } - | { action: "clear_status" } - | { action: "create_channel"; target: Servers.Server } - | { action: "create_invite"; target: Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel } - | { action: "leave_group"; target: Channels.GroupChannel } - | { action: "delete_channel"; target: Channels.TextChannel | Channels.VoiceChannel } - | { action: "close_dm"; target: Channels.DirectMessageChannel } - | { action: "leave_server"; target: Servers.Server } - | { action: "delete_server"; target: Servers.Server } - | { action: "open_notification_options", channel: Channels.Channel } - | { action: "open_settings" } - | { action: "open_channel_settings", id: string } - | { action: "open_server_settings", id: string } - | { action: "open_server_channel_settings", server: string, id: string } - | { action: "set_notification_state", key: string, state?: NotificationState }; + | { action: "copy_id"; id: string } + | { action: "copy_selection" } + | { action: "copy_text"; content: string } + | { action: "mark_as_read"; channel: Channels.Channel } + | { action: "retry_message"; message: QueuedMessage } + | { action: "cancel_message"; message: QueuedMessage } + | { action: "mention"; user: string } + | { action: "reply_message"; id: string } + | { action: "quote_message"; content: string } + | { action: "edit_message"; id: string } + | { action: "delete_message"; target: Channels.Message } + | { action: "open_file"; attachment: Attachment } + | { action: "save_file"; attachment: Attachment } + | { action: "copy_file_link"; attachment: Attachment } + | { action: "open_link"; link: string } + | { action: "copy_link"; link: string } + | { action: "remove_member"; channel: string; user: string } + | { action: "kick_member"; target: Servers.Server; user: string } + | { action: "ban_member"; target: Servers.Server; user: string } + | { action: "view_profile"; user: string } + | { action: "message_user"; user: string } + | { action: "block_user"; user: Users.User } + | { action: "unblock_user"; user: Users.User } + | { action: "add_friend"; user: Users.User } + | { action: "remove_friend"; user: Users.User } + | { action: "cancel_friend"; user: Users.User } + | { action: "set_presence"; presence: Users.Presence } + | { action: "set_status" } + | { action: "clear_status" } + | { action: "create_channel"; target: Servers.Server } + | { + action: "create_invite"; + target: + | Channels.GroupChannel + | Channels.TextChannel + | Channels.VoiceChannel; + } + | { action: "leave_group"; target: Channels.GroupChannel } + | { + action: "delete_channel"; + target: Channels.TextChannel | Channels.VoiceChannel; + } + | { action: "close_dm"; target: Channels.DirectMessageChannel } + | { action: "leave_server"; target: Servers.Server } + | { action: "delete_server"; target: Servers.Server } + | { action: "open_notification_options"; channel: Channels.Channel } + | { action: "open_settings" } + | { action: "open_channel_settings"; id: string } + | { action: "open_server_settings"; id: string } + | { action: "open_server_channel_settings"; server: string; id: string } + | { + action: "set_notification_state"; + key: string; + state?: NotificationState; + }; type Props = { - notifications: Notifications + notifications: Notifications; }; function ContextMenus(props: Props) { - const { openScreen, writeClipboard } = useIntermediate(); - const client = useContext(AppContext); - const userId = client.user!._id; - const status = useContext(StatusContext); - const isOnline = status === ClientStatus.ONLINE; - const history = useHistory(); - - function contextClick(data?: Action) { - if (typeof data === "undefined") return; - - (async () => { - switch (data.action) { - case "copy_id": - writeClipboard(data.id); - break; - case "copy_selection": - writeClipboard(document.getSelection()?.toString() ?? ''); - break; - case "mark_as_read": - { - if (data.channel.channel_type === 'SavedMessages' || - data.channel.channel_type === 'VoiceChannel') return; - - let message = data.channel.channel_type === 'TextChannel' ? data.channel.last_message : data.channel.last_message._id; - dispatch({ - type: "UNREADS_MARK_READ", - channel: data.channel._id, - message - }); - - client.req('PUT', `/channels/${data.channel._id}/ack/${message}` as '/channels/id/ack/id'); - } - break; - - case "retry_message": - { - const nonce = data.message.id; - const fail = (error: any) => - dispatch({ - type: "QUEUE_FAIL", - nonce, - error - }); - - client.channels - .sendMessage( - data.message.channel, - { - nonce: data.message.id, - content: data.message.data.content as string, - replies: data.message.data.replies - } - ) - .catch(fail); - - dispatch({ - type: "QUEUE_START", - nonce - }); - } - break; - - case "cancel_message": - { - dispatch({ - type: "QUEUE_REMOVE", - nonce: data.message.id - }); - } - break; - - case "mention": - { - internalEmit( - "MessageBox", - "append", - `<@${data.user}>`, - "mention" - ); - } - break; - - case "copy_text": - writeClipboard(data.content); - break; - - case "reply_message": - { - internalEmit( - "ReplyBar", - "add", - data.id - ); - } - break; - - case "quote_message": - { - internalEmit( - "MessageBox", - "append", - data.content, - "quote" - ); - } - break; - - case "edit_message": - { - internalEmit("MessageRenderer", "edit_message", data.id); - } - break; - - case "open_file": - { - window - .open( - client.generateFileURL(data.attachment), - "_blank" - ) - ?.focus(); - } - break; - - case "save_file": - { - window.open( - // ! FIXME: do this from revolt.js - client.generateFileURL(data.attachment)?.replace('attachments', 'attachments/download'), - "_blank" - ); - } - break; - - case "copy_file_link": - { - const { filename } = data.attachment; - writeClipboard( - // ! FIXME: do from r.js - client.generateFileURL(data.attachment) + `/${encodeURI(filename)}`, - ); - } - break; - - case "open_link": - { - window.open(data.link, "_blank")?.focus(); - } - break; - - case "copy_link": - { - writeClipboard(data.link); - } - break; - - case "remove_member": - { - client.channels.removeMember(data.channel, data.user); - } - break; - - case "view_profile": - openScreen({ id: 'profile', user_id: data.user }); - break; - - case "message_user": - { - const channel = await client.users.openDM(data.user); - if (channel) { - history.push(`/channel/${channel._id}`); - } - } - break; - - case "add_friend": - { - await client.users.addFriend(data.user.username); - } - break; - - case "block_user": - openScreen({ id: 'special_prompt', type: 'block_user', target: data.user }); - break; - case "unblock_user": - await client.users.unblockUser(data.user._id); - break; - case "remove_friend": - openScreen({ id: 'special_prompt', type: 'unfriend_user', target: data.user }); - break; - case "cancel_friend": - await client.users.removeFriend(data.user._id); - break; - - case "set_presence": - { - await client.users.editUser({ - status: { - ...client.user?.status, - presence: data.presence - } - }); - } - break; - - case "set_status": openScreen({ id: "special_input", type: "set_custom_status" }); break; - - case "clear_status": - { - let { text, ...status } = client.user?.status ?? {}; - await client.users.editUser({ status }); - } - break; - - case "leave_group": - case "close_dm": - case "leave_server": - case "delete_channel": - case "delete_server": - case "delete_message": - case "create_channel": - case "create_invite": - // The any here is because typescript flattens the case types into a single type and type structure and specifity is lost or whatever - openScreen({ id: "special_prompt", type: data.action, target: data.target as any }); - break; - - case "ban_member": - case "kick_member": - openScreen({ id: "special_prompt", type: data.action, target: data.target, user: data.user }); - break; - - case "open_notification_options": { - openContextMenu("NotificationOptions", { channel: data.channel }); - break; - } - - case "open_settings": history.push('/settings'); break; - case "open_channel_settings": history.push(`/channel/${data.id}/settings`); break; - case "open_server_channel_settings": history.push(`/server/${data.server}/channel/${data.id}/settings`); break; - case "open_server_settings": history.push(`/server/${data.id}/settings`); break; - - case "set_notification_state": { - const { key, state } = data; - if (state) { - dispatch({ type: "NOTIFICATIONS_SET", key, state }); - } else { - dispatch({ type: "NOTIFICATIONS_REMOVE", key }); - } - break; - } - } - })().catch(err => { - openScreen({ id: "error", error: takeError(err) }); - }); - } - - return ( - <> - <ContextMenuWithData id="Menu" onClose={contextClick}> - {({ - user: uid, - channel: cid, - server: sid, - message, - server_list, - queued, - unread, - contextualChannel: cxid - }: ContextMenuData) => { - const forceUpdate = useForceUpdate(); - const elements: Children[] = []; - var lastDivider = false; - - function generateAction( - action: Action, - locale?: string, - disabled?: boolean, - tip?: Children - ) { - lastDivider = false; - elements.push( - <MenuItem data={action} disabled={disabled}> - <Text - id={`app.context_menu.${locale ?? - action.action}`} - /> - { tip && <div className="tip"> - { tip } - </div> } - </MenuItem> - ); - } - - function pushDivider() { - if (lastDivider || elements.length === 0) return; - lastDivider = true; - elements.push(<LineDivider />); - } - - if (server_list) { - let server = useServer(server_list, forceUpdate); - let permissions = useServerPermission(server_list, forceUpdate); - if (server) { - if (permissions & ServerPermission.ManageChannels) generateAction({ action: 'create_channel', target: server }); - if (permissions & ServerPermission.ManageServer) generateAction({ action: 'open_server_settings', id: server_list }); - } - - return elements; - } - - if (document.getSelection()?.toString().length ?? 0 > 0) { - generateAction({ action: "copy_selection" }, undefined, undefined, <Text id="shortcuts.ctrlc" />); - pushDivider(); - } - - const channel = useChannel(cid, forceUpdate); - const contextualChannel = useChannel(cxid, forceUpdate); - const targetChannel = channel ?? contextualChannel; - - const user = useUser(uid, forceUpdate); - const serverChannel = targetChannel && (targetChannel.channel_type === 'TextChannel' || targetChannel.channel_type === 'VoiceChannel') ? targetChannel : undefined; - const server = useServer(serverChannel ? serverChannel.server : sid, forceUpdate); - - const channelPermissions = targetChannel ? useChannelPermission(targetChannel._id, forceUpdate) : 0; - const serverPermissions = server ? useServerPermission(server._id, forceUpdate) : ( - serverChannel ? useServerPermission(serverChannel.server, forceUpdate) : 0 - ); - const userPermissions = user ? useUserPermission(user._id, forceUpdate) : 0; - - if (channel && unread) { - generateAction({ action: "mark_as_read", channel }); - } - - if (contextualChannel) { - if (user && user._id !== userId) { - generateAction({ - action: "mention", - user: user._id - }); - - pushDivider(); - } - } - - if (user) { - let actions: Action['action'][]; - switch (user.relationship) { - case Users.Relationship.User: actions = []; break; - case Users.Relationship.Friend: - actions = [ - "remove_friend", - "block_user" - ]; - break; - case Users.Relationship.Incoming: - actions = ["add_friend", "cancel_friend", "block_user"]; - break; - case Users.Relationship.Outgoing: - actions = ["cancel_friend", "block_user"]; - break; - case Users.Relationship.Blocked: - actions = ["unblock_user"]; - break; - case Users.Relationship.BlockedOther: - actions = ["block_user"]; - break; - case Users.Relationship.None: - default: - actions = ["add_friend", "block_user"]; - } - - if (userPermissions & UserPermission.ViewProfile) { - generateAction({ action: 'view_profile', user: user._id }); - } - - if (user._id !== userId && userPermissions & UserPermission.SendMessage) { - generateAction({ action: 'message_user', user: user._id }); - } - - for(let i = 0; i < actions.length; i++) { - // The any here is because typescript can't determine that user the actions are linked together correctly - generateAction({ action: actions[i] as any, user }) - } - } - - if (contextualChannel) { - if (contextualChannel.channel_type === "Group" && uid) { - if ( - contextualChannel.owner === userId && - userId !== uid - ) { - generateAction({ - action: "remove_member", - channel: contextualChannel._id, - user: uid - }); - } - } - - if (server && uid && userId !== uid && uid !== server.owner) { - if (serverPermissions & ServerPermission.KickMembers) - generateAction({ action: "kick_member", target: server, user: uid }); - - if (serverPermissions & ServerPermission.BanMembers) - generateAction({ action: "ban_member", target: server, user: uid }); - } - } - - if (queued) { - generateAction({ - action: "retry_message", - message: queued - }); - - generateAction({ - action: "cancel_message", - message: queued - }); - } - - if (message && !queued) { - generateAction({ - action: "reply_message", - id: message._id - }); - - if ( - typeof message.content === "string" && - message.content.length > 0 - ) { - generateAction({ - action: "quote_message", - content: message.content - }); - - generateAction({ - action: "copy_text", - content: message.content - }); - } - - if (message.author === userId) { - generateAction({ - action: "edit_message", - id: message._id - }); - } - - if (message.author === userId || - channelPermissions & ChannelPermission.ManageMessages) { - generateAction({ - action: "delete_message", - target: message - }); - } - - if (message.attachments) { - pushDivider(); - const { metadata } = message.attachments[0]; - const { type } = metadata; - - generateAction( - { - action: "open_file", - attachment: message.attachments[0] - }, - type === "Image" - ? "open_image" - : type === "Video" - ? "open_video" - : "open_file" - ); - - generateAction( - { - action: "save_file", - attachment: message.attachments[0] - }, - type === "Image" - ? "save_image" - : type === "Video" - ? "save_video" - : "save_file" - ); - - generateAction( - { - action: "copy_file_link", - attachment: message.attachments[0] - }, - "copy_link" - ); - } - - if (document.activeElement?.tagName === "A") { - let link = document.activeElement.getAttribute( - "href" - ); - if (link) { - pushDivider(); - generateAction({ action: "open_link", link }); - generateAction({ action: "copy_link", link }); - } - } - } - - let id = sid ?? cid ?? uid ?? message?._id; - if (id) { - pushDivider(); - - if (channel) { - if (channel.channel_type !== 'VoiceChannel') { - generateAction({ action: "open_notification_options", channel }, undefined, undefined, <ChevronRight size={24} />); - } - - switch (channel.channel_type) { - case 'Group': - // ! generateAction({ action: "create_invite", target: channel }); FIXME: add support for group invites - generateAction({ action: "open_channel_settings", id: channel._id }, "open_group_settings"); - generateAction({ action: "leave_group", target: channel }, "leave_group"); - break; - case 'DirectMessage': - generateAction({ action: "close_dm", target: channel }); - break; - case 'TextChannel': - case 'VoiceChannel': - // ! FIXME: add permission for invites - generateAction({ action: "create_invite", target: channel }); - - if (serverPermissions & ServerPermission.ManageServer) - generateAction({ action: "open_server_channel_settings", server: channel.server, id: channel._id }, "open_channel_settings"); - - if (serverPermissions & ServerPermission.ManageChannels) - generateAction({ action: "delete_channel", target: channel }); - - break; - } - } - - if (sid && server) { - if (serverPermissions & ServerPermission.ManageServer) - generateAction({ action: "open_server_settings", id: server._id }, "open_server_settings"); - - if (userId === server.owner) { - generateAction({ action: "delete_server", target: server }, "delete_server"); - } else { - generateAction({ action: "leave_server", target: server }, "leave_server"); - } - } - - generateAction( - { action: "copy_id", id }, - sid ? "copy_sid" : - cid ? "copy_cid" : - message ? "copy_mid" : "copy_uid" - ); - } - - return elements; - }} - </ContextMenuWithData> - <ContextMenuWithData id="Status" onClose={contextClick} className="Status"> - {() => <> - <div className="header"> - <div className="main"> - <div>@{client.user!.username}</div> - <div className="status"><UserStatus user={client.user!} /></div> - </div> - <IconButton> - <MenuItem data={{ action: "open_settings" }}> - <Cog size={18} /> - </MenuItem> - </IconButton> - </div> - <LineDivider /> - <MenuItem - data={{ - action: "set_presence", - presence: Users.Presence.Online - }} - disabled={!isOnline} - > - <div className="indicator online" /> - <Text id={`app.status.online`} /> - </MenuItem> - <MenuItem - data={{ - action: "set_presence", - presence: Users.Presence.Idle - }} - disabled={!isOnline} - > - <div className="indicator idle" /> - <Text id={`app.status.idle`} /> - </MenuItem> - <MenuItem - data={{ - action: "set_presence", - presence: Users.Presence.Busy - }} - disabled={!isOnline} - > - <div className="indicator busy" /> - <Text id={`app.status.busy`} /> - </MenuItem> - <MenuItem - data={{ - action: "set_presence", - presence: Users.Presence.Invisible - }} - disabled={!isOnline} - > - <div className="indicator invisible" /> - <Text id={`app.status.invisible`} /> - </MenuItem> - <LineDivider /> - <div className="header"> - <div className="main"> - <MenuItem data={{ action: "set_status" }} disabled={!isOnline}> - <Text id={`app.context_menu.custom_status`} /> - </MenuItem> - </div> - { client.user!.status?.text && <IconButton> - <MenuItem data={{ action: "clear_status" }}> - <Trash size={18} /> - </MenuItem> - </IconButton> } - </div> - </>} - </ContextMenuWithData> - <ContextMenuWithData id="NotificationOptions" onClose={contextClick}> - {({ channel }: { channel: Channels.Channel }) => { - const state = props.notifications[channel._id]; - const actual = getNotificationState(props.notifications, channel); - - let elements: Children[] = [ - <MenuItem data={{ action: "set_notification_state", key: channel._id }}> - <Text id={`app.main.channel.notifications.default`} /> - <div className="tip"> - { (state !== undefined) && <Square size={20} /> } - { (state === undefined) && <CheckSquare size={20} /> } - </div> - </MenuItem> - ]; - - function generate(key: string, icon: Children) { - elements.push( - <MenuItem data={{ action: "set_notification_state", key: channel._id, state: key }}> - { icon } - <Text id={`app.main.channel.notifications.${key}`} /> - { (state === undefined && actual === key) && <div className="tip"><LeftArrowAlt size={20} /></div> } - { (state === key) && <div className="tip"><Check size={20} /></div> } - </MenuItem> - ); - } - - generate('all', <Bell size={24} />); - generate('mention', <At size={24} />); - generate('muted', <BellOff size={24} />); - generate('none', <Block size={24} />); - - return elements; - }} - </ContextMenuWithData> - </> - ); + const { openScreen, writeClipboard } = useIntermediate(); + const client = useContext(AppContext); + const userId = client.user!._id; + const status = useContext(StatusContext); + const isOnline = status === ClientStatus.ONLINE; + const history = useHistory(); + + function contextClick(data?: Action) { + if (typeof data === "undefined") return; + + (async () => { + switch (data.action) { + case "copy_id": + writeClipboard(data.id); + break; + case "copy_selection": + writeClipboard(document.getSelection()?.toString() ?? ""); + break; + case "mark_as_read": + { + if ( + data.channel.channel_type === "SavedMessages" || + data.channel.channel_type === "VoiceChannel" + ) + return; + + let message = + data.channel.channel_type === "TextChannel" + ? data.channel.last_message + : data.channel.last_message._id; + dispatch({ + type: "UNREADS_MARK_READ", + channel: data.channel._id, + message, + }); + + client.req( + "PUT", + `/channels/${data.channel._id}/ack/${message}` as "/channels/id/ack/id", + ); + } + break; + + case "retry_message": + { + const nonce = data.message.id; + const fail = (error: any) => + dispatch({ + type: "QUEUE_FAIL", + nonce, + error, + }); + + client.channels + .sendMessage(data.message.channel, { + nonce: data.message.id, + content: data.message.data.content as string, + replies: data.message.data.replies, + }) + .catch(fail); + + dispatch({ + type: "QUEUE_START", + nonce, + }); + } + break; + + case "cancel_message": + { + dispatch({ + type: "QUEUE_REMOVE", + nonce: data.message.id, + }); + } + break; + + case "mention": + { + internalEmit( + "MessageBox", + "append", + `<@${data.user}>`, + "mention", + ); + } + break; + + case "copy_text": + writeClipboard(data.content); + break; + + case "reply_message": + { + internalEmit("ReplyBar", "add", data.id); + } + break; + + case "quote_message": + { + internalEmit( + "MessageBox", + "append", + data.content, + "quote", + ); + } + break; + + case "edit_message": + { + internalEmit( + "MessageRenderer", + "edit_message", + data.id, + ); + } + break; + + case "open_file": + { + window + .open( + client.generateFileURL(data.attachment), + "_blank", + ) + ?.focus(); + } + break; + + case "save_file": + { + window.open( + // ! FIXME: do this from revolt.js + client + .generateFileURL(data.attachment) + ?.replace( + "attachments", + "attachments/download", + ), + "_blank", + ); + } + break; + + case "copy_file_link": + { + const { filename } = data.attachment; + writeClipboard( + // ! FIXME: do from r.js + client.generateFileURL(data.attachment) + + `/${encodeURI(filename)}`, + ); + } + break; + + case "open_link": + { + window.open(data.link, "_blank")?.focus(); + } + break; + + case "copy_link": + { + writeClipboard(data.link); + } + break; + + case "remove_member": + { + client.channels.removeMember(data.channel, data.user); + } + break; + + case "view_profile": + openScreen({ id: "profile", user_id: data.user }); + break; + + case "message_user": + { + const channel = await client.users.openDM(data.user); + if (channel) { + history.push(`/channel/${channel._id}`); + } + } + break; + + case "add_friend": + { + await client.users.addFriend(data.user.username); + } + break; + + case "block_user": + openScreen({ + id: "special_prompt", + type: "block_user", + target: data.user, + }); + break; + case "unblock_user": + await client.users.unblockUser(data.user._id); + break; + case "remove_friend": + openScreen({ + id: "special_prompt", + type: "unfriend_user", + target: data.user, + }); + break; + case "cancel_friend": + await client.users.removeFriend(data.user._id); + break; + + case "set_presence": + { + await client.users.editUser({ + status: { + ...client.user?.status, + presence: data.presence, + }, + }); + } + break; + + case "set_status": + openScreen({ + id: "special_input", + type: "set_custom_status", + }); + break; + + case "clear_status": + { + let { text, ...status } = client.user?.status ?? {}; + await client.users.editUser({ status }); + } + break; + + case "leave_group": + case "close_dm": + case "leave_server": + case "delete_channel": + case "delete_server": + case "delete_message": + case "create_channel": + case "create_invite": + // The any here is because typescript flattens the case types into a single type and type structure and specifity is lost or whatever + openScreen({ + id: "special_prompt", + type: data.action, + target: data.target as any, + }); + break; + + case "ban_member": + case "kick_member": + openScreen({ + id: "special_prompt", + type: data.action, + target: data.target, + user: data.user, + }); + break; + + case "open_notification_options": { + openContextMenu("NotificationOptions", { + channel: data.channel, + }); + break; + } + + case "open_settings": + history.push("/settings"); + break; + case "open_channel_settings": + history.push(`/channel/${data.id}/settings`); + break; + case "open_server_channel_settings": + history.push( + `/server/${data.server}/channel/${data.id}/settings`, + ); + break; + case "open_server_settings": + history.push(`/server/${data.id}/settings`); + break; + + case "set_notification_state": { + const { key, state } = data; + if (state) { + dispatch({ type: "NOTIFICATIONS_SET", key, state }); + } else { + dispatch({ type: "NOTIFICATIONS_REMOVE", key }); + } + break; + } + } + })().catch((err) => { + openScreen({ id: "error", error: takeError(err) }); + }); + } + + return ( + <> + <ContextMenuWithData id="Menu" onClose={contextClick}> + {({ + user: uid, + channel: cid, + server: sid, + message, + server_list, + queued, + unread, + contextualChannel: cxid, + }: ContextMenuData) => { + const forceUpdate = useForceUpdate(); + const elements: Children[] = []; + var lastDivider = false; + + function generateAction( + action: Action, + locale?: string, + disabled?: boolean, + tip?: Children, + ) { + lastDivider = false; + elements.push( + <MenuItem data={action} disabled={disabled}> + <Text + id={`app.context_menu.${ + locale ?? action.action + }`} + /> + {tip && <div className="tip">{tip}</div>} + </MenuItem>, + ); + } + + function pushDivider() { + if (lastDivider || elements.length === 0) return; + lastDivider = true; + elements.push(<LineDivider />); + } + + if (server_list) { + let server = useServer(server_list, forceUpdate); + let permissions = useServerPermission( + server_list, + forceUpdate, + ); + if (server) { + if (permissions & ServerPermission.ManageChannels) + generateAction({ + action: "create_channel", + target: server, + }); + if (permissions & ServerPermission.ManageServer) + generateAction({ + action: "open_server_settings", + id: server_list, + }); + } + + return elements; + } + + if (document.getSelection()?.toString().length ?? 0 > 0) { + generateAction( + { action: "copy_selection" }, + undefined, + undefined, + <Text id="shortcuts.ctrlc" />, + ); + pushDivider(); + } + + const channel = useChannel(cid, forceUpdate); + const contextualChannel = useChannel(cxid, forceUpdate); + const targetChannel = channel ?? contextualChannel; + + const user = useUser(uid, forceUpdate); + const serverChannel = + targetChannel && + (targetChannel.channel_type === "TextChannel" || + targetChannel.channel_type === "VoiceChannel") + ? targetChannel + : undefined; + const server = useServer( + serverChannel ? serverChannel.server : sid, + forceUpdate, + ); + + const channelPermissions = targetChannel + ? useChannelPermission(targetChannel._id, forceUpdate) + : 0; + const serverPermissions = server + ? useServerPermission(server._id, forceUpdate) + : serverChannel + ? useServerPermission(serverChannel.server, forceUpdate) + : 0; + const userPermissions = user + ? useUserPermission(user._id, forceUpdate) + : 0; + + if (channel && unread) { + generateAction({ action: "mark_as_read", channel }); + } + + if (contextualChannel) { + if (user && user._id !== userId) { + generateAction({ + action: "mention", + user: user._id, + }); + + pushDivider(); + } + } + + if (user) { + let actions: Action["action"][]; + switch (user.relationship) { + case Users.Relationship.User: + actions = []; + break; + case Users.Relationship.Friend: + actions = ["remove_friend", "block_user"]; + break; + case Users.Relationship.Incoming: + actions = [ + "add_friend", + "cancel_friend", + "block_user", + ]; + break; + case Users.Relationship.Outgoing: + actions = ["cancel_friend", "block_user"]; + break; + case Users.Relationship.Blocked: + actions = ["unblock_user"]; + break; + case Users.Relationship.BlockedOther: + actions = ["block_user"]; + break; + case Users.Relationship.None: + default: + actions = ["add_friend", "block_user"]; + } + + if (userPermissions & UserPermission.ViewProfile) { + generateAction({ + action: "view_profile", + user: user._id, + }); + } + + if ( + user._id !== userId && + userPermissions & UserPermission.SendMessage + ) { + generateAction({ + action: "message_user", + user: user._id, + }); + } + + for (let i = 0; i < actions.length; i++) { + // The any here is because typescript can't determine that user the actions are linked together correctly + generateAction({ action: actions[i] as any, user }); + } + } + + if (contextualChannel) { + if (contextualChannel.channel_type === "Group" && uid) { + if ( + contextualChannel.owner === userId && + userId !== uid + ) { + generateAction({ + action: "remove_member", + channel: contextualChannel._id, + user: uid, + }); + } + } + + if ( + server && + uid && + userId !== uid && + uid !== server.owner + ) { + if ( + serverPermissions & ServerPermission.KickMembers + ) + generateAction({ + action: "kick_member", + target: server, + user: uid, + }); + + if (serverPermissions & ServerPermission.BanMembers) + generateAction({ + action: "ban_member", + target: server, + user: uid, + }); + } + } + + if (queued) { + generateAction({ + action: "retry_message", + message: queued, + }); + + generateAction({ + action: "cancel_message", + message: queued, + }); + } + + if (message && !queued) { + generateAction({ + action: "reply_message", + id: message._id, + }); + + if ( + typeof message.content === "string" && + message.content.length > 0 + ) { + generateAction({ + action: "quote_message", + content: message.content, + }); + + generateAction({ + action: "copy_text", + content: message.content, + }); + } + + if (message.author === userId) { + generateAction({ + action: "edit_message", + id: message._id, + }); + } + + if ( + message.author === userId || + channelPermissions & + ChannelPermission.ManageMessages + ) { + generateAction({ + action: "delete_message", + target: message, + }); + } + + if (message.attachments) { + pushDivider(); + const { metadata } = message.attachments[0]; + const { type } = metadata; + + generateAction( + { + action: "open_file", + attachment: message.attachments[0], + }, + type === "Image" + ? "open_image" + : type === "Video" + ? "open_video" + : "open_file", + ); + + generateAction( + { + action: "save_file", + attachment: message.attachments[0], + }, + type === "Image" + ? "save_image" + : type === "Video" + ? "save_video" + : "save_file", + ); + + generateAction( + { + action: "copy_file_link", + attachment: message.attachments[0], + }, + "copy_link", + ); + } + + if (document.activeElement?.tagName === "A") { + let link = + document.activeElement.getAttribute("href"); + if (link) { + pushDivider(); + generateAction({ action: "open_link", link }); + generateAction({ action: "copy_link", link }); + } + } + } + + let id = sid ?? cid ?? uid ?? message?._id; + if (id) { + pushDivider(); + + if (channel) { + if (channel.channel_type !== "VoiceChannel") { + generateAction( + { + action: "open_notification_options", + channel, + }, + undefined, + undefined, + <ChevronRight size={24} />, + ); + } + + switch (channel.channel_type) { + case "Group": + // ! generateAction({ action: "create_invite", target: channel }); FIXME: add support for group invites + generateAction( + { + action: "open_channel_settings", + id: channel._id, + }, + "open_group_settings", + ); + generateAction( + { + action: "leave_group", + target: channel, + }, + "leave_group", + ); + break; + case "DirectMessage": + generateAction({ + action: "close_dm", + target: channel, + }); + break; + case "TextChannel": + case "VoiceChannel": + // ! FIXME: add permission for invites + generateAction({ + action: "create_invite", + target: channel, + }); + + if ( + serverPermissions & + ServerPermission.ManageServer + ) + generateAction( + { + action: "open_server_channel_settings", + server: channel.server, + id: channel._id, + }, + "open_channel_settings", + ); + + if ( + serverPermissions & + ServerPermission.ManageChannels + ) + generateAction({ + action: "delete_channel", + target: channel, + }); + + break; + } + } + + if (sid && server) { + if ( + serverPermissions & + ServerPermission.ManageServer + ) + generateAction( + { + action: "open_server_settings", + id: server._id, + }, + "open_server_settings", + ); + + if (userId === server.owner) { + generateAction( + { action: "delete_server", target: server }, + "delete_server", + ); + } else { + generateAction( + { action: "leave_server", target: server }, + "leave_server", + ); + } + } + + generateAction( + { action: "copy_id", id }, + sid + ? "copy_sid" + : cid + ? "copy_cid" + : message + ? "copy_mid" + : "copy_uid", + ); + } + + return elements; + }} + </ContextMenuWithData> + <ContextMenuWithData + id="Status" + onClose={contextClick} + className="Status"> + {() => ( + <> + <div className="header"> + <div className="main"> + <div>@{client.user!.username}</div> + <div className="status"> + <UserStatus user={client.user!} /> + </div> + </div> + <IconButton> + <MenuItem data={{ action: "open_settings" }}> + <Cog size={18} /> + </MenuItem> + </IconButton> + </div> + <LineDivider /> + <MenuItem + data={{ + action: "set_presence", + presence: Users.Presence.Online, + }} + disabled={!isOnline}> + <div className="indicator online" /> + <Text id={`app.status.online`} /> + </MenuItem> + <MenuItem + data={{ + action: "set_presence", + presence: Users.Presence.Idle, + }} + disabled={!isOnline}> + <div className="indicator idle" /> + <Text id={`app.status.idle`} /> + </MenuItem> + <MenuItem + data={{ + action: "set_presence", + presence: Users.Presence.Busy, + }} + disabled={!isOnline}> + <div className="indicator busy" /> + <Text id={`app.status.busy`} /> + </MenuItem> + <MenuItem + data={{ + action: "set_presence", + presence: Users.Presence.Invisible, + }} + disabled={!isOnline}> + <div className="indicator invisible" /> + <Text id={`app.status.invisible`} /> + </MenuItem> + <LineDivider /> + <div className="header"> + <div className="main"> + <MenuItem + data={{ action: "set_status" }} + disabled={!isOnline}> + <Text + id={`app.context_menu.custom_status`} + /> + </MenuItem> + </div> + {client.user!.status?.text && ( + <IconButton> + <MenuItem data={{ action: "clear_status" }}> + <Trash size={18} /> + </MenuItem> + </IconButton> + )} + </div> + </> + )} + </ContextMenuWithData> + <ContextMenuWithData + id="NotificationOptions" + onClose={contextClick}> + {({ channel }: { channel: Channels.Channel }) => { + const state = props.notifications[channel._id]; + const actual = getNotificationState( + props.notifications, + channel, + ); + + let elements: Children[] = [ + <MenuItem + data={{ + action: "set_notification_state", + key: channel._id, + }}> + <Text + id={`app.main.channel.notifications.default`} + /> + <div className="tip"> + {state !== undefined && <Square size={20} />} + {state === undefined && ( + <CheckSquare size={20} /> + )} + </div> + </MenuItem>, + ]; + + function generate(key: string, icon: Children) { + elements.push( + <MenuItem + data={{ + action: "set_notification_state", + key: channel._id, + state: key, + }}> + {icon} + <Text + id={`app.main.channel.notifications.${key}`} + /> + {state === undefined && actual === key && ( + <div className="tip"> + <LeftArrowAlt size={20} /> + </div> + )} + {state === key && ( + <div className="tip"> + <Check size={20} /> + </div> + )} + </MenuItem>, + ); + } + + generate("all", <Bell size={24} />); + generate("mention", <At size={24} />); + generate("muted", <BellOff size={24} />); + generate("none", <Block size={24} />); + + return elements; + }} + </ContextMenuWithData> + </> + ); } -export default connectState( - ContextMenus, - state => { - return { - notifications: state.notifications - }; - } -); +export default connectState(ContextMenus, (state) => { + return { + notifications: state.notifications, + }; +}); diff --git a/src/lib/PaintCounter.tsx b/src/lib/PaintCounter.tsx index 487a015c08de2964d20e405c5cc014d75ea27180..15cd5c37c9d0b6324ca056e5f8404c803a3d4d3e 100644 --- a/src/lib/PaintCounter.tsx +++ b/src/lib/PaintCounter.tsx @@ -2,17 +2,21 @@ import { useState } from "preact/hooks"; const counts: { [key: string]: number } = {}; -export default function PaintCounter({ small, always }: { small?: boolean, always?: boolean }) { - if (import.meta.env.PROD && !always) return null; +export default function PaintCounter({ + small, + always, +}: { + small?: boolean; + always?: boolean; +}) { + if (import.meta.env.PROD && !always) return null; - const [uniqueId] = useState('' + Math.random()); - const count = counts[uniqueId] ?? 0; - counts[uniqueId] = count + 1; - return ( - <div style={{ textAlign: 'center', fontSize: '0.8em' }}> - { small ? <>P: { count + 1 }</> : <> - Painted {count + 1} time(s). - </> } - </div> - ) + const [uniqueId] = useState("" + Math.random()); + const count = counts[uniqueId] ?? 0; + counts[uniqueId] = count + 1; + return ( + <div style={{ textAlign: "center", fontSize: "0.8em" }}> + {small ? <>P: {count + 1}</> : <>Painted {count + 1} time(s).</>} + </div> + ); } diff --git a/src/lib/TextAreaAutoSize.tsx b/src/lib/TextAreaAutoSize.tsx index c602655489632aa84eee5c96aff38d16dbccb220..a01cc5757b286e4f23d552bfe043cc5ad2151f3a 100644 --- a/src/lib/TextAreaAutoSize.tsx +++ b/src/lib/TextAreaAutoSize.tsx @@ -1,80 +1,113 @@ import { useEffect, useRef } from "preact/hooks"; + +import TextArea, { + DEFAULT_LINE_HEIGHT, + DEFAULT_TEXT_AREA_PADDING, + TextAreaProps, + TEXT_AREA_BORDER_WIDTH, +} from "../components/ui/TextArea"; + import { internalSubscribe } from "./eventEmitter"; import { isTouchscreenDevice } from "./isTouchscreenDevice"; -import TextArea, { DEFAULT_LINE_HEIGHT, DEFAULT_TEXT_AREA_PADDING, TextAreaProps, TEXT_AREA_BORDER_WIDTH } from "../components/ui/TextArea"; -type TextAreaAutoSizeProps = Omit<JSX.HTMLAttributes<HTMLTextAreaElement>, 'style' | 'value'> & TextAreaProps & { - forceFocus?: boolean - autoFocus?: boolean - minHeight?: number - maxRows?: number - value: string +type TextAreaAutoSizeProps = Omit< + JSX.HTMLAttributes<HTMLTextAreaElement>, + "style" | "value" +> & + TextAreaProps & { + forceFocus?: boolean; + autoFocus?: boolean; + minHeight?: number; + maxRows?: number; + value: string; - id?: string -}; + id?: string; + }; export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) { - const { autoFocus, minHeight, maxRows, value, padding, lineHeight, hideBorder, forceFocus, children, as, ...textAreaProps } = props; - const line = lineHeight ?? DEFAULT_LINE_HEIGHT; - - const heightPadding = ((padding ?? DEFAULT_TEXT_AREA_PADDING) + (hideBorder ? 0 : TEXT_AREA_BORDER_WIDTH)) * 2; - const height = Math.max(Math.min(value.split('\n').length, maxRows ?? Infinity) * line + heightPadding, minHeight ?? 0); - - const ref = useRef<HTMLTextAreaElement>(); - - useEffect(() => { - if (isTouchscreenDevice) return; - autoFocus && ref.current.focus(); - }, [value]); - - const inputSelected = () => - ["TEXTAREA", "INPUT"].includes(document.activeElement?.nodeName ?? ""); - - useEffect(() => { - if (forceFocus) { - ref.current.focus(); - } - - if (isTouchscreenDevice) return; - if (autoFocus && !inputSelected()) { - ref.current.focus(); - } - - // ? if you are wondering what this is - // ? it is a quick and dirty hack to fix - // ? value not setting correctly - // ? I have no clue what's going on - ref.current.value = value; - - if (!autoFocus) return; - function keyDown(e: KeyboardEvent) { - if ((e.ctrlKey && e.key !== "v") || e.altKey || e.metaKey) return; - if (e.key.length !== 1) return; - if (ref && !inputSelected()) { - ref.current.focus(); - } - } - - document.body.addEventListener("keydown", keyDown); - return () => document.body.removeEventListener("keydown", keyDown); - }, [ref]); - - useEffect(() => { - function focus(id: string) { - if (id === props.id) { - ref.current.focus(); - } - } - - return internalSubscribe("TextArea", "focus", focus); - }, [ref]); - - return <TextArea - ref={ref} - value={value} - padding={padding} - style={{ height }} - hideBorder={hideBorder} - lineHeight={lineHeight} - {...textAreaProps} />; + const { + autoFocus, + minHeight, + maxRows, + value, + padding, + lineHeight, + hideBorder, + forceFocus, + children, + as, + ...textAreaProps + } = props; + const line = lineHeight ?? DEFAULT_LINE_HEIGHT; + + const heightPadding = + ((padding ?? DEFAULT_TEXT_AREA_PADDING) + + (hideBorder ? 0 : TEXT_AREA_BORDER_WIDTH)) * + 2; + const height = Math.max( + Math.min(value.split("\n").length, maxRows ?? Infinity) * line + + heightPadding, + minHeight ?? 0, + ); + + const ref = useRef<HTMLTextAreaElement>(); + + useEffect(() => { + if (isTouchscreenDevice) return; + autoFocus && ref.current.focus(); + }, [value]); + + const inputSelected = () => + ["TEXTAREA", "INPUT"].includes(document.activeElement?.nodeName ?? ""); + + useEffect(() => { + if (forceFocus) { + ref.current.focus(); + } + + if (isTouchscreenDevice) return; + if (autoFocus && !inputSelected()) { + ref.current.focus(); + } + + // ? if you are wondering what this is + // ? it is a quick and dirty hack to fix + // ? value not setting correctly + // ? I have no clue what's going on + ref.current.value = value; + + if (!autoFocus) return; + function keyDown(e: KeyboardEvent) { + if ((e.ctrlKey && e.key !== "v") || e.altKey || e.metaKey) return; + if (e.key.length !== 1) return; + if (ref && !inputSelected()) { + ref.current.focus(); + } + } + + document.body.addEventListener("keydown", keyDown); + return () => document.body.removeEventListener("keydown", keyDown); + }, [ref]); + + useEffect(() => { + function focus(id: string) { + if (id === props.id) { + ref.current.focus(); + } + } + + return internalSubscribe("TextArea", "focus", focus); + }, [ref]); + + return ( + <TextArea + ref={ref} + value={value} + padding={padding} + style={{ height }} + hideBorder={hideBorder} + lineHeight={lineHeight} + {...textAreaProps} + /> + ); } diff --git a/src/lib/conversion.ts b/src/lib/conversion.ts index 61dcad9d378514732edc34c7111d330bf81d33c2..263dd5b119907fc81b7f633465e50add510cd062 100644 --- a/src/lib/conversion.ts +++ b/src/lib/conversion.ts @@ -1,9 +1,9 @@ export function urlBase64ToUint8Array(base64String: string) { - const padding = "=".repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding) - .replace(/\-/g, "+") - .replace(/_/g, "/"); - const rawData = window.atob(base64); + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, "+") + .replace(/_/g, "/"); + const rawData = window.atob(base64); - return Uint8Array.from([...rawData].map(char => char.charCodeAt(0))); + return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); } diff --git a/src/lib/debounce.ts b/src/lib/debounce.ts index b65292bd4486ebac17ca8dfbd484652f68463849..2b7d63ae3544a65c71ab8abdd227711187b91650 100644 --- a/src/lib/debounce.ts +++ b/src/lib/debounce.ts @@ -1,15 +1,15 @@ export function debounce(cb: Function, duration: number) { - // Store the timer variable. - let timer: NodeJS.Timeout; - // This function is given to React. - return (...args: any[]) => { - // Get rid of the old timer. - clearTimeout(timer); - // Set a new timer. - timer = setTimeout(() => { - // Instead calling the new function. - // (with the newer data) - cb(...args); - }, duration); - }; + // Store the timer variable. + let timer: NodeJS.Timeout; + // This function is given to React. + return (...args: any[]) => { + // Get rid of the old timer. + clearTimeout(timer); + // Set a new timer. + timer = setTimeout(() => { + // Instead calling the new function. + // (with the newer data) + cb(...args); + }, duration); + }; } diff --git a/src/lib/eventEmitter.ts b/src/lib/eventEmitter.ts index ae0ae52e89ecb75cf3e466503bbe2f8aa50ddfc3..131f1bf75098eed1571e4238bdaa2f4c3790a96c 100644 --- a/src/lib/eventEmitter.ts +++ b/src/lib/eventEmitter.ts @@ -1,13 +1,18 @@ import EventEmitter from "eventemitter3"; + export const InternalEvent = new EventEmitter(); -export function internalSubscribe(ns: string, event: string, fn: (...args: any[]) => void) { - InternalEvent.addListener(ns + '/' + event, fn); - return () => InternalEvent.removeListener(ns + '/' + event, fn); +export function internalSubscribe( + ns: string, + event: string, + fn: (...args: any[]) => void, +) { + InternalEvent.addListener(ns + "/" + event, fn); + return () => InternalEvent.removeListener(ns + "/" + event, fn); } export function internalEmit(ns: string, event: string, ...args: any[]) { - InternalEvent.emit(ns + '/' + event, ...args); + InternalEvent.emit(ns + "/" + event, ...args); } // Event structure: namespace/event diff --git a/src/lib/fileSize.ts b/src/lib/fileSize.ts index c6bba81d55577507c59070adb713ef186bf28acd..9caea0cf734b758252bd615bdf53286b42e9041b 100644 --- a/src/lib/fileSize.ts +++ b/src/lib/fileSize.ts @@ -1,9 +1,9 @@ export function determineFileSize(size: number) { - if (size > 1e6) { - return `${(size / 1e6).toFixed(2)} MB`; - } else if (size > 1e3) { - return `${(size / 1e3).toFixed(2)} KB`; - } + if (size > 1e6) { + return `${(size / 1e6).toFixed(2)} MB`; + } else if (size > 1e3) { + return `${(size / 1e3).toFixed(2)} KB`; + } - return `${size} B`; + return `${size} B`; } diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index a58dff14a608828cac37490602356f021122efe8..e8ca877773bd43774c91194e9bf3244bc5bf87ac 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -1,59 +1,62 @@ import { IntlContext, translate } from "preact-i18n"; import { useContext } from "preact/hooks"; + import { Children } from "../types/Preact"; interface Fields { - [key: string]: Children + [key: string]: Children; } interface Props { - id: string; - fields: Fields + id: string; + fields: Fields; } export interface IntlType { - intl: { - dictionary: { - [key: string]: Object | string - } - } + intl: { + dictionary: { + [key: string]: Object | string; + }; + }; } // This will exhibit O(2^n) behaviour. function recursiveReplaceFields(input: string, fields: Fields) { - const key = Object.keys(fields)[0]; - if (key) { - const { [key]: field, ...restOfFields } = fields; - if (typeof field === 'undefined') return [ input ]; - - const values: (Children | string[])[] = input.split(`{{${key}}}`) - .map(v => recursiveReplaceFields(v, restOfFields)); - - for (let i=values.length - 1;i>0;i-=2) { - values.splice(i, 0, field); - } - - return values.flat(); - } else { - // base case - return [ input ]; - } + const key = Object.keys(fields)[0]; + if (key) { + const { [key]: field, ...restOfFields } = fields; + if (typeof field === "undefined") return [input]; + + const values: (Children | string[])[] = input + .split(`{{${key}}}`) + .map((v) => recursiveReplaceFields(v, restOfFields)); + + for (let i = values.length - 1; i > 0; i -= 2) { + values.splice(i, 0, field); + } + + return values.flat(); + } else { + // base case + return [input]; + } } export function TextReact({ id, fields }: Props) { - const { intl } = useContext(IntlContext) as unknown as IntlType; + const { intl } = useContext(IntlContext) as unknown as IntlType; - const path = id.split('.'); - let entry = intl.dictionary[path.shift()!]; - for (let key of path) { - // @ts-expect-error - entry = entry[key]; - } + const path = id.split("."); + let entry = intl.dictionary[path.shift()!]; + for (let key of path) { + // @ts-expect-error + entry = entry[key]; + } - return <>{ recursiveReplaceFields(entry as string, fields) }</>; + return <>{recursiveReplaceFields(entry as string, fields)}</>; } export function useTranslation() { - const { intl } = useContext(IntlContext) as unknown as IntlType; - return (id: string, fields?: Object, plural?: number, fallback?: string) => translate(id, "", intl.dictionary, fields, plural, fallback); + const { intl } = useContext(IntlContext) as unknown as IntlType; + return (id: string, fields?: Object, plural?: number, fallback?: string) => + translate(id, "", intl.dictionary, fields, plural, fallback); } diff --git a/src/lib/isTouchscreenDevice.ts b/src/lib/isTouchscreenDevice.ts index a91bb23b978a22595880fc9f92bd5946d7750418..6f2dc0ab713b2009a202ce142b0c89cc568d1705 100644 --- a/src/lib/isTouchscreenDevice.ts +++ b/src/lib/isTouchscreenDevice.ts @@ -1,7 +1,8 @@ import { isDesktop, isMobile, isTablet } from "react-device-detect"; + export const isTouchscreenDevice = - isDesktop && !isTablet - ? false - : (typeof window !== "undefined" - ? navigator.maxTouchPoints > 0 - : false) || isMobile; + isDesktop && !isTablet + ? false + : (typeof window !== "undefined" + ? navigator.maxTouchPoints > 0 + : false) || isMobile; diff --git a/src/lib/renderer/Singleton.ts b/src/lib/renderer/Singleton.ts index da4acadbe0f3694499d8fd2a21ca1c3b6c0cb78a..fd71d194996f17a6159562f48afafbfbf17d3591 100644 --- a/src/lib/renderer/Singleton.ts +++ b/src/lib/renderer/Singleton.ts @@ -1,192 +1,206 @@ -import { RendererRoutines, RenderState, ScrollState } from "./types"; -import { SimpleRenderer } from "./simple/SimpleRenderer"; -import { useEffect, useState } from "preact/hooks"; -import EventEmitter3 from 'eventemitter3'; +import EventEmitter3 from "eventemitter3"; import { Client, Message } from "revolt.js"; +import { useEffect, useState } from "preact/hooks"; + +import { SimpleRenderer } from "./simple/SimpleRenderer"; +import { RendererRoutines, RenderState, ScrollState } from "./types"; + export const SMOOTH_SCROLL_ON_RECEIVE = false; export class SingletonRenderer extends EventEmitter3 { - client?: Client; - channel?: string; - state: RenderState; - currentRenderer: RendererRoutines; - - stale = false; - fetchingTop = false; - fetchingBottom = false; - - constructor() { - super(); - - this.receive = this.receive.bind(this); - this.edit = this.edit.bind(this); - this.delete = this.delete.bind(this); - - this.state = { type: 'LOADING' }; - this.currentRenderer = SimpleRenderer; - } - - private receive(message: Message) { - this.currentRenderer.receive(this, message); - } - - private edit(id: string, patch: Partial<Message>) { - this.currentRenderer.edit(this, id, patch); - } - - private delete(id: string) { - this.currentRenderer.delete(this, id); - } - - subscribe(client: Client) { - if (this.client) { - this.client.removeListener('message', this.receive); - this.client.removeListener('message/update', this.edit); - this.client.removeListener('message/delete', this.delete); - } - - this.client = client; - client.addListener('message', this.receive); - client.addListener('message/update', this.edit); - client.addListener('message/delete', this.delete); - } - - private setStateUnguarded(state: RenderState, scroll?: ScrollState) { - this.state = state; - this.emit('state', state); - - if (scroll) { - this.emit('scroll', scroll); - } - } - - setState(id: string, state: RenderState, scroll?: ScrollState) { - if (id !== this.channel) return; - this.setStateUnguarded(state, scroll); - } - - markStale() { - this.stale = true; - } - - async init(id: string) { - this.channel = id; - this.stale = false; - this.setStateUnguarded({ type: 'LOADING' }); - await this.currentRenderer.init(this, id); - } - - async reloadStale(id: string) { - if (this.stale) { - this.stale = false; - await this.init(id); - } - } - - async loadTop(ref?: HTMLDivElement) { - if (this.fetchingTop) return; - this.fetchingTop = true; - - function generateScroll(end: string): ScrollState { - if (ref) { - let heightRemoved = 0; - let messageContainer = ref.children[0]; - if (messageContainer) { - for (let child of Array.from(messageContainer.children)) { - // If this child has a ulid. - if (child.id?.length === 26) { - // Check whether it was removed. - if (child.id.localeCompare(end) === 1) { - heightRemoved += child.clientHeight + - // We also need to take into account the top margin of the container. - parseInt(window.getComputedStyle(child).marginTop.slice(0, -2)); - } - } - } - } - - return { - type: 'OffsetTop', - previousHeight: ref.scrollHeight - heightRemoved - } - } else { - return { - type: 'OffsetTop', - previousHeight: 0 - } - } - } - - await this.currentRenderer.loadTop(this, generateScroll); - - // Allow state updates to propagate. - setTimeout(() => this.fetchingTop = false, 0); - } - - async loadBottom(ref?: HTMLDivElement) { - if (this.fetchingBottom) return; - this.fetchingBottom = true; - - function generateScroll(start: string): ScrollState { - if (ref) { - let heightRemoved = 0; - let messageContainer = ref.children[0]; - if (messageContainer) { - for (let child of Array.from(messageContainer.children)) { - // If this child has a ulid. - if (child.id?.length === 26) { - // Check whether it was removed. - if (child.id.localeCompare(start) === -1) { - heightRemoved += child.clientHeight + - // We also need to take into account the top margin of the container. - parseInt(window.getComputedStyle(child).marginTop.slice(0, -2)); - } - } - } - } - - return { - type: 'ScrollTop', - y: ref.scrollTop - heightRemoved - } - } else { - return { - type: 'ScrollToBottom' - } - } - } - - await this.currentRenderer.loadBottom(this, generateScroll); - - // Allow state updates to propagate. - setTimeout(() => this.fetchingBottom = false, 0); - } - - async jumpToBottom(id: string, smooth: boolean) { - if (id !== this.channel) return; - if (this.state.type === 'RENDER' && this.state.atBottom) { - this.emit('scroll', { type: 'ScrollToBottom', smooth }); - } else { - await this.currentRenderer.init(this, id, true); - } - } + client?: Client; + channel?: string; + state: RenderState; + currentRenderer: RendererRoutines; + + stale = false; + fetchingTop = false; + fetchingBottom = false; + + constructor() { + super(); + + this.receive = this.receive.bind(this); + this.edit = this.edit.bind(this); + this.delete = this.delete.bind(this); + + this.state = { type: "LOADING" }; + this.currentRenderer = SimpleRenderer; + } + + private receive(message: Message) { + this.currentRenderer.receive(this, message); + } + + private edit(id: string, patch: Partial<Message>) { + this.currentRenderer.edit(this, id, patch); + } + + private delete(id: string) { + this.currentRenderer.delete(this, id); + } + + subscribe(client: Client) { + if (this.client) { + this.client.removeListener("message", this.receive); + this.client.removeListener("message/update", this.edit); + this.client.removeListener("message/delete", this.delete); + } + + this.client = client; + client.addListener("message", this.receive); + client.addListener("message/update", this.edit); + client.addListener("message/delete", this.delete); + } + + private setStateUnguarded(state: RenderState, scroll?: ScrollState) { + this.state = state; + this.emit("state", state); + + if (scroll) { + this.emit("scroll", scroll); + } + } + + setState(id: string, state: RenderState, scroll?: ScrollState) { + if (id !== this.channel) return; + this.setStateUnguarded(state, scroll); + } + + markStale() { + this.stale = true; + } + + async init(id: string) { + this.channel = id; + this.stale = false; + this.setStateUnguarded({ type: "LOADING" }); + await this.currentRenderer.init(this, id); + } + + async reloadStale(id: string) { + if (this.stale) { + this.stale = false; + await this.init(id); + } + } + + async loadTop(ref?: HTMLDivElement) { + if (this.fetchingTop) return; + this.fetchingTop = true; + + function generateScroll(end: string): ScrollState { + if (ref) { + let heightRemoved = 0; + let messageContainer = ref.children[0]; + if (messageContainer) { + for (let child of Array.from(messageContainer.children)) { + // If this child has a ulid. + if (child.id?.length === 26) { + // Check whether it was removed. + if (child.id.localeCompare(end) === 1) { + heightRemoved += + child.clientHeight + + // We also need to take into account the top margin of the container. + parseInt( + window + .getComputedStyle(child) + .marginTop.slice(0, -2), + ); + } + } + } + } + + return { + type: "OffsetTop", + previousHeight: ref.scrollHeight - heightRemoved, + }; + } else { + return { + type: "OffsetTop", + previousHeight: 0, + }; + } + } + + await this.currentRenderer.loadTop(this, generateScroll); + + // Allow state updates to propagate. + setTimeout(() => (this.fetchingTop = false), 0); + } + + async loadBottom(ref?: HTMLDivElement) { + if (this.fetchingBottom) return; + this.fetchingBottom = true; + + function generateScroll(start: string): ScrollState { + if (ref) { + let heightRemoved = 0; + let messageContainer = ref.children[0]; + if (messageContainer) { + for (let child of Array.from(messageContainer.children)) { + // If this child has a ulid. + if (child.id?.length === 26) { + // Check whether it was removed. + if (child.id.localeCompare(start) === -1) { + heightRemoved += + child.clientHeight + + // We also need to take into account the top margin of the container. + parseInt( + window + .getComputedStyle(child) + .marginTop.slice(0, -2), + ); + } + } + } + } + + return { + type: "ScrollTop", + y: ref.scrollTop - heightRemoved, + }; + } else { + return { + type: "ScrollToBottom", + }; + } + } + + await this.currentRenderer.loadBottom(this, generateScroll); + + // Allow state updates to propagate. + setTimeout(() => (this.fetchingBottom = false), 0); + } + + async jumpToBottom(id: string, smooth: boolean) { + if (id !== this.channel) return; + if (this.state.type === "RENDER" && this.state.atBottom) { + this.emit("scroll", { type: "ScrollToBottom", smooth }); + } else { + await this.currentRenderer.init(this, id, true); + } + } } export const SingletonMessageRenderer = new SingletonRenderer(); export function useRenderState(id: string) { - const [state, setState] = useState<Readonly<RenderState>>(SingletonMessageRenderer.state); - if (typeof id === "undefined") return; + const [state, setState] = useState<Readonly<RenderState>>( + SingletonMessageRenderer.state, + ); + if (typeof id === "undefined") return; - function render(state: RenderState) { - setState(state); - } + function render(state: RenderState) { + setState(state); + } - useEffect(() => { - SingletonMessageRenderer.addListener("state", render); - return () => SingletonMessageRenderer.removeListener("state", render); - }, [id]); + useEffect(() => { + SingletonMessageRenderer.addListener("state", render); + return () => SingletonMessageRenderer.removeListener("state", render); + }, [id]); - return state; + return state; } diff --git a/src/lib/renderer/simple/SimpleRenderer.ts b/src/lib/renderer/simple/SimpleRenderer.ts index afd808886d2c48a878f9d465a861ed32e8be9c4f..ce4da1de32890e6fbc094ee61cf52295b6f8f879 100644 --- a/src/lib/renderer/simple/SimpleRenderer.ts +++ b/src/lib/renderer/simple/SimpleRenderer.ts @@ -1,178 +1,183 @@ import { mapMessage } from "../../../context/revoltjs/util"; + import { SMOOTH_SCROLL_ON_RECEIVE } from "../Singleton"; import { RendererRoutines } from "../types"; export const SimpleRenderer: RendererRoutines = { - init: async (renderer, id, smooth) => { - if (renderer.client!.websocket.connected) { - renderer.client!.channels - .fetchMessagesWithUsers(id, { }, true) - .then(({ messages: data }) => { - data.reverse(); - let messages = data.map(x => mapMessage(x)); - renderer.setState( - id, - { - type: 'RENDER', - messages, - atTop: data.length < 50, - atBottom: true - }, - { type: 'ScrollToBottom', smooth } - ); - }); - } else { - renderer.setState(id, { type: 'WAITING_FOR_NETWORK' }); - } - }, - receive: async (renderer, message) => { - if (message.channel !== renderer.channel) return; - if (renderer.state.type !== 'RENDER') return; - if (renderer.state.messages.find(x => x._id === message._id)) return; - if (!renderer.state.atBottom) return; - - let messages = [ ...renderer.state.messages, mapMessage(message) ]; - let atTop = renderer.state.atTop; - if (messages.length > 150) { - messages = messages.slice(messages.length - 150); - atTop = false; - } - - renderer.setState( - message.channel, - { - ...renderer.state, - messages, - atTop - }, - { type: 'StayAtBottom', smooth: SMOOTH_SCROLL_ON_RECEIVE } - ); - }, - edit: async (renderer, id, patch) => { - const channel = renderer.channel; - if (!channel) return; - if (renderer.state.type !== 'RENDER') return; - - let messages = [ ...renderer.state.messages ]; - let index = messages.findIndex(x => x._id === id); - - if (index > -1) { - let message = { ...messages[index], ...mapMessage(patch) }; - messages.splice(index, 1, message); - - renderer.setState( - channel, - { - ...renderer.state, - messages - }, - { type: 'StayAtBottom' } - ); - } - }, - delete: async (renderer, id) => { - const channel = renderer.channel; - if (!channel) return; - if (renderer.state.type !== 'RENDER') return; - - let messages = [ ...renderer.state.messages ]; - let index = messages.findIndex(x => x._id === id); - - if (index > -1) { - messages.splice(index, 1); - - renderer.setState( - channel, - { - ...renderer.state, - messages - }, - { type: 'StayAtBottom' } - ); - } - }, - loadTop: async (renderer, generateScroll) => { - const channel = renderer.channel; - if (!channel) return; - - const state = renderer.state; - if (state.type !== 'RENDER') return; - if (state.atTop) return; - - const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, { - before: state.messages[0]._id - }, true); - - if (data.length === 0) { - return renderer.setState( - channel, - { - ...state, - atTop: true - } - ); - } - - data.reverse(); - let messages = [ ...data.map(x => mapMessage(x)), ...state.messages ]; - - let atTop = false; - if (data.length < 50) { - atTop = true; - } - - let atBottom = state.atBottom; - if (messages.length > 150) { - messages = messages.slice(0, 150); - atBottom = false; - } - - renderer.setState( - channel, - { ...state, atTop, atBottom, messages }, - generateScroll(messages[messages.length - 1]._id) - ); - }, - loadBottom: async (renderer, generateScroll) => { - const channel = renderer.channel; - if (!channel) return; - - const state = renderer.state; - if (state.type !== 'RENDER') return; - if (state.atBottom) return; - - const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, { - after: state.messages[state.messages.length - 1]._id, - sort: 'Oldest' - }, true); - - if (data.length === 0) { - return renderer.setState( - channel, - { - ...state, - atBottom: true - } - ); - } - - let messages = [ ...state.messages, ...data.map(x => mapMessage(x)) ]; - - let atBottom = false; - if (data.length < 50) { - atBottom = true; - } - - let atTop = state.atTop; - if (messages.length > 150) { - messages = messages.slice(messages.length - 150); - atTop = false; - } - - renderer.setState( - channel, - { ...state, atTop, atBottom, messages }, - generateScroll(messages[0]._id) - ); - } + init: async (renderer, id, smooth) => { + if (renderer.client!.websocket.connected) { + renderer + .client!.channels.fetchMessagesWithUsers(id, {}, true) + .then(({ messages: data }) => { + data.reverse(); + let messages = data.map((x) => mapMessage(x)); + renderer.setState( + id, + { + type: "RENDER", + messages, + atTop: data.length < 50, + atBottom: true, + }, + { type: "ScrollToBottom", smooth }, + ); + }); + } else { + renderer.setState(id, { type: "WAITING_FOR_NETWORK" }); + } + }, + receive: async (renderer, message) => { + if (message.channel !== renderer.channel) return; + if (renderer.state.type !== "RENDER") return; + if (renderer.state.messages.find((x) => x._id === message._id)) return; + if (!renderer.state.atBottom) return; + + let messages = [...renderer.state.messages, mapMessage(message)]; + let atTop = renderer.state.atTop; + if (messages.length > 150) { + messages = messages.slice(messages.length - 150); + atTop = false; + } + + renderer.setState( + message.channel, + { + ...renderer.state, + messages, + atTop, + }, + { type: "StayAtBottom", smooth: SMOOTH_SCROLL_ON_RECEIVE }, + ); + }, + edit: async (renderer, id, patch) => { + const channel = renderer.channel; + if (!channel) return; + if (renderer.state.type !== "RENDER") return; + + let messages = [...renderer.state.messages]; + let index = messages.findIndex((x) => x._id === id); + + if (index > -1) { + let message = { ...messages[index], ...mapMessage(patch) }; + messages.splice(index, 1, message); + + renderer.setState( + channel, + { + ...renderer.state, + messages, + }, + { type: "StayAtBottom" }, + ); + } + }, + delete: async (renderer, id) => { + const channel = renderer.channel; + if (!channel) return; + if (renderer.state.type !== "RENDER") return; + + let messages = [...renderer.state.messages]; + let index = messages.findIndex((x) => x._id === id); + + if (index > -1) { + messages.splice(index, 1); + + renderer.setState( + channel, + { + ...renderer.state, + messages, + }, + { type: "StayAtBottom" }, + ); + } + }, + loadTop: async (renderer, generateScroll) => { + const channel = renderer.channel; + if (!channel) return; + + const state = renderer.state; + if (state.type !== "RENDER") return; + if (state.atTop) return; + + const { messages: data } = + await renderer.client!.channels.fetchMessagesWithUsers( + channel, + { + before: state.messages[0]._id, + }, + true, + ); + + if (data.length === 0) { + return renderer.setState(channel, { + ...state, + atTop: true, + }); + } + + data.reverse(); + let messages = [...data.map((x) => mapMessage(x)), ...state.messages]; + + let atTop = false; + if (data.length < 50) { + atTop = true; + } + + let atBottom = state.atBottom; + if (messages.length > 150) { + messages = messages.slice(0, 150); + atBottom = false; + } + + renderer.setState( + channel, + { ...state, atTop, atBottom, messages }, + generateScroll(messages[messages.length - 1]._id), + ); + }, + loadBottom: async (renderer, generateScroll) => { + const channel = renderer.channel; + if (!channel) return; + + const state = renderer.state; + if (state.type !== "RENDER") return; + if (state.atBottom) return; + + const { messages: data } = + await renderer.client!.channels.fetchMessagesWithUsers( + channel, + { + after: state.messages[state.messages.length - 1]._id, + sort: "Oldest", + }, + true, + ); + + if (data.length === 0) { + return renderer.setState(channel, { + ...state, + atBottom: true, + }); + } + + let messages = [...state.messages, ...data.map((x) => mapMessage(x))]; + + let atBottom = false; + if (data.length < 50) { + atBottom = true; + } + + let atTop = state.atTop; + if (messages.length > 150) { + messages = messages.slice(messages.length - 150); + atTop = false; + } + + renderer.setState( + channel, + { ...state, atTop, atBottom, messages }, + generateScroll(messages[0]._id), + ); + }, }; diff --git a/src/lib/renderer/types.ts b/src/lib/renderer/types.ts index da6709ff420d013c237f9ecaca0e8c67a7d0e010..29bb682a62f24dd97c0dbe43d3f35baa9e2ee69b 100644 --- a/src/lib/renderer/types.ts +++ b/src/lib/renderer/types.ts @@ -1,32 +1,48 @@ import { Message } from "revolt.js"; -import { SingletonRenderer } from "./Singleton"; + import { MessageObject } from "../../context/revoltjs/util"; +import { SingletonRenderer } from "./Singleton"; + export type ScrollState = - | { type: "Free" } - | { type: "Bottom", scrollingUntil?: number } - | { type: "ScrollToBottom" | "StayAtBottom", smooth?: boolean } - | { type: "OffsetTop"; previousHeight: number } - | { type: "ScrollTop"; y: number }; + | { type: "Free" } + | { type: "Bottom"; scrollingUntil?: number } + | { type: "ScrollToBottom" | "StayAtBottom"; smooth?: boolean } + | { type: "OffsetTop"; previousHeight: number } + | { type: "ScrollTop"; y: number }; export type RenderState = - | { - type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY"; - } - | { - type: "RENDER"; - atTop: boolean; - atBottom: boolean; - messages: MessageObject[]; - }; + | { + type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY"; + } + | { + type: "RENDER"; + atTop: boolean; + atBottom: boolean; + messages: MessageObject[]; + }; export interface RendererRoutines { - init: (renderer: SingletonRenderer, id: string, smooth?: boolean) => Promise<void> - - receive: (renderer: SingletonRenderer, message: Message) => Promise<void>; - edit: (renderer: SingletonRenderer, id: string, partial: Partial<Message>) => Promise<void>; - delete: (renderer: SingletonRenderer, id: string) => Promise<void>; + init: ( + renderer: SingletonRenderer, + id: string, + smooth?: boolean, + ) => Promise<void>; + + receive: (renderer: SingletonRenderer, message: Message) => Promise<void>; + edit: ( + renderer: SingletonRenderer, + id: string, + partial: Partial<Message>, + ) => Promise<void>; + delete: (renderer: SingletonRenderer, id: string) => Promise<void>; - loadTop: (renderer: SingletonRenderer, generateScroll: (end: string) => ScrollState) => Promise<void>; - loadBottom: (renderer: SingletonRenderer, generateScroll: (start: string) => ScrollState) => Promise<void>; + loadTop: ( + renderer: SingletonRenderer, + generateScroll: (end: string) => ScrollState, + ) => Promise<void>; + loadBottom: ( + renderer: SingletonRenderer, + generateScroll: (start: string) => ScrollState, + ) => Promise<void>; } diff --git a/src/lib/stopPropagation.ts b/src/lib/stopPropagation.ts index df4d95337e7ea16b311b07137f5f4d7df1fe6cb1..0a74bae8e7528ce56b76457dc622c1c24dfd4bbb 100644 --- a/src/lib/stopPropagation.ts +++ b/src/lib/stopPropagation.ts @@ -1,5 +1,8 @@ -export const stopPropagation = (ev: JSX.TargetedMouseEvent<HTMLDivElement>, _consume?: any) => { - ev.preventDefault(); - ev.stopPropagation(); - return true; +export const stopPropagation = ( + ev: JSX.TargetedMouseEvent<HTMLDivElement>, + _consume?: any, +) => { + ev.preventDefault(); + ev.stopPropagation(); + return true; }; diff --git a/src/lib/vortex/Signaling.ts b/src/lib/vortex/Signaling.ts index b54a6110174688a3b1956f01c019776115b1a598..5def9042b186c5e26fb6aaf15737f09fe1b47514 100644 --- a/src/lib/vortex/Signaling.ts +++ b/src/lib/vortex/Signaling.ts @@ -1,188 +1,189 @@ import EventEmitter from "eventemitter3"; + import { - RtpCapabilities, - RtpParameters + RtpCapabilities, + RtpParameters, } from "mediasoup-client/lib/RtpParameters"; import { DtlsParameters } from "mediasoup-client/lib/Transport"; import { - AuthenticationResult, - Room, - TransportInitDataTuple, - WSCommandType, - WSErrorCode, - ProduceType, - ConsumerData + AuthenticationResult, + Room, + TransportInitDataTuple, + WSCommandType, + WSErrorCode, + ProduceType, + ConsumerData, } from "./Types"; interface SignalingEvents { - open: (event: Event) => void; - close: (event: CloseEvent) => void; - error: (event: Event) => void; - data: (data: any) => void; + open: (event: Event) => void; + close: (event: CloseEvent) => void; + error: (event: Event) => void; + data: (data: any) => void; } export default class Signaling extends EventEmitter<SignalingEvents> { - ws?: WebSocket; - index: number; - pending: Map<number, (data: unknown) => void>; - - constructor() { - super(); - this.index = 0; - this.pending = new Map(); - } - - connected(): boolean { - return ( - this.ws !== undefined && - this.ws.readyState !== WebSocket.CLOSING && - this.ws.readyState !== WebSocket.CLOSED - ); - } - - connect(address: string): Promise<void> { - this.disconnect(); - this.ws = new WebSocket(address); - this.ws.onopen = e => this.emit("open", e); - this.ws.onclose = e => this.emit("close", e); - this.ws.onerror = e => this.emit("error", e); - this.ws.onmessage = e => this.parseData(e); - - let finished = false; - return new Promise((resolve, reject) => { - this.once("open", () => { - if (finished) return; - finished = true; - resolve(); - }); - - this.once("error", () => { - if (finished) return; - finished = true; - reject(); - }); - }); - } - - disconnect() { - if ( - this.ws !== undefined && - this.ws.readyState !== WebSocket.CLOSED && - this.ws.readyState !== WebSocket.CLOSING - ) - this.ws.close(1000); - } - - private parseData(event: MessageEvent) { - if (typeof event.data !== "string") return; - const json = JSON.parse(event.data); - const entry = this.pending.get(json.id); - if (entry === undefined) { - this.emit("data", json); - return; - } - - entry(json); - } - - sendRequest(type: string, data?: any): Promise<any> { - if (this.ws === undefined || this.ws.readyState !== WebSocket.OPEN) - return Promise.reject({ error: WSErrorCode.NotConnected }); - - const ws = this.ws; - return new Promise((resolve, reject) => { - if (this.index >= 2 ** 32) this.index = 0; - while (this.pending.has(this.index)) this.index++; - const onClose = (e: CloseEvent) => { - reject({ - error: e.code, - message: e.reason - }); - }; - - const finishedFn = (data: any) => { - this.removeListener("close", onClose); - if (data.error) - reject({ - error: data.error, - message: data.message, - data: data.data - }); - resolve(data.data); - }; - - this.pending.set(this.index, finishedFn); - this.once("close", onClose); - const json = { - id: this.index, - type: type, - data - }; - ws.send(JSON.stringify(json) + "\n"); - this.index++; - }); - } - - authenticate(token: string, roomId: string): Promise<AuthenticationResult> { - return this.sendRequest(WSCommandType.Authenticate, { token, roomId }); - } - - async roomInfo(): Promise<Room> { - const room = await this.sendRequest(WSCommandType.RoomInfo); - return { - id: room.id, - videoAllowed: room.videoAllowed, - users: new Map(Object.entries(room.users)) - }; - } - - initializeTransports( - rtpCapabilities: RtpCapabilities - ): Promise<TransportInitDataTuple> { - return this.sendRequest(WSCommandType.InitializeTransports, { - mode: "SplitWebRTC", - rtpCapabilities - }); - } - - connectTransport( - id: string, - dtlsParameters: DtlsParameters - ): Promise<void> { - return this.sendRequest(WSCommandType.ConnectTransport, { - id, - dtlsParameters - }); - } - - async startProduce( - type: ProduceType, - rtpParameters: RtpParameters - ): Promise<string> { - let result = await this.sendRequest(WSCommandType.StartProduce, { - type, - rtpParameters - }); - return result.producerId; - } - - stopProduce(type: ProduceType): Promise<void> { - return this.sendRequest(WSCommandType.StopProduce, { type }); - } - - startConsume(userId: string, type: ProduceType): Promise<ConsumerData> { - return this.sendRequest(WSCommandType.StartConsume, { type, userId }); - } - - stopConsume(consumerId: string): Promise<void> { - return this.sendRequest(WSCommandType.StopConsume, { id: consumerId }); - } - - setConsumerPause(consumerId: string, paused: boolean): Promise<void> { - return this.sendRequest(WSCommandType.SetConsumerPause, { - id: consumerId, - paused - }); - } + ws?: WebSocket; + index: number; + pending: Map<number, (data: unknown) => void>; + + constructor() { + super(); + this.index = 0; + this.pending = new Map(); + } + + connected(): boolean { + return ( + this.ws !== undefined && + this.ws.readyState !== WebSocket.CLOSING && + this.ws.readyState !== WebSocket.CLOSED + ); + } + + connect(address: string): Promise<void> { + this.disconnect(); + this.ws = new WebSocket(address); + this.ws.onopen = (e) => this.emit("open", e); + this.ws.onclose = (e) => this.emit("close", e); + this.ws.onerror = (e) => this.emit("error", e); + this.ws.onmessage = (e) => this.parseData(e); + + let finished = false; + return new Promise((resolve, reject) => { + this.once("open", () => { + if (finished) return; + finished = true; + resolve(); + }); + + this.once("error", () => { + if (finished) return; + finished = true; + reject(); + }); + }); + } + + disconnect() { + if ( + this.ws !== undefined && + this.ws.readyState !== WebSocket.CLOSED && + this.ws.readyState !== WebSocket.CLOSING + ) + this.ws.close(1000); + } + + private parseData(event: MessageEvent) { + if (typeof event.data !== "string") return; + const json = JSON.parse(event.data); + const entry = this.pending.get(json.id); + if (entry === undefined) { + this.emit("data", json); + return; + } + + entry(json); + } + + sendRequest(type: string, data?: any): Promise<any> { + if (this.ws === undefined || this.ws.readyState !== WebSocket.OPEN) + return Promise.reject({ error: WSErrorCode.NotConnected }); + + const ws = this.ws; + return new Promise((resolve, reject) => { + if (this.index >= 2 ** 32) this.index = 0; + while (this.pending.has(this.index)) this.index++; + const onClose = (e: CloseEvent) => { + reject({ + error: e.code, + message: e.reason, + }); + }; + + const finishedFn = (data: any) => { + this.removeListener("close", onClose); + if (data.error) + reject({ + error: data.error, + message: data.message, + data: data.data, + }); + resolve(data.data); + }; + + this.pending.set(this.index, finishedFn); + this.once("close", onClose); + const json = { + id: this.index, + type: type, + data, + }; + ws.send(JSON.stringify(json) + "\n"); + this.index++; + }); + } + + authenticate(token: string, roomId: string): Promise<AuthenticationResult> { + return this.sendRequest(WSCommandType.Authenticate, { token, roomId }); + } + + async roomInfo(): Promise<Room> { + const room = await this.sendRequest(WSCommandType.RoomInfo); + return { + id: room.id, + videoAllowed: room.videoAllowed, + users: new Map(Object.entries(room.users)), + }; + } + + initializeTransports( + rtpCapabilities: RtpCapabilities, + ): Promise<TransportInitDataTuple> { + return this.sendRequest(WSCommandType.InitializeTransports, { + mode: "SplitWebRTC", + rtpCapabilities, + }); + } + + connectTransport( + id: string, + dtlsParameters: DtlsParameters, + ): Promise<void> { + return this.sendRequest(WSCommandType.ConnectTransport, { + id, + dtlsParameters, + }); + } + + async startProduce( + type: ProduceType, + rtpParameters: RtpParameters, + ): Promise<string> { + let result = await this.sendRequest(WSCommandType.StartProduce, { + type, + rtpParameters, + }); + return result.producerId; + } + + stopProduce(type: ProduceType): Promise<void> { + return this.sendRequest(WSCommandType.StopProduce, { type }); + } + + startConsume(userId: string, type: ProduceType): Promise<ConsumerData> { + return this.sendRequest(WSCommandType.StartConsume, { type, userId }); + } + + stopConsume(consumerId: string): Promise<void> { + return this.sendRequest(WSCommandType.StopConsume, { id: consumerId }); + } + + setConsumerPause(consumerId: string, paused: boolean): Promise<void> { + return this.sendRequest(WSCommandType.SetConsumerPause, { + id: consumerId, + paused, + }); + } } diff --git a/src/lib/vortex/Types.ts b/src/lib/vortex/Types.ts index ceaf4e912f4acb45efca74a371b7646e68c7593a..5ab09a43e94ecbe4f5ef16bc0706c2b19086fe99 100644 --- a/src/lib/vortex/Types.ts +++ b/src/lib/vortex/Types.ts @@ -1,111 +1,111 @@ import { Consumer } from "mediasoup-client/lib/Consumer"; import { - MediaKind, - RtpCapabilities, - RtpParameters + MediaKind, + RtpCapabilities, + RtpParameters, } from "mediasoup-client/lib/RtpParameters"; import { SctpParameters } from "mediasoup-client/lib/SctpParameters"; import { - DtlsParameters, - IceCandidate, - IceParameters + DtlsParameters, + IceCandidate, + IceParameters, } from "mediasoup-client/lib/Transport"; export enum WSEventType { - UserJoined = "UserJoined", - UserLeft = "UserLeft", + UserJoined = "UserJoined", + UserLeft = "UserLeft", - UserStartProduce = "UserStartProduce", - UserStopProduce = "UserStopProduce" + UserStartProduce = "UserStartProduce", + UserStopProduce = "UserStopProduce", } export enum WSCommandType { - Authenticate = "Authenticate", - RoomInfo = "RoomInfo", + Authenticate = "Authenticate", + RoomInfo = "RoomInfo", - InitializeTransports = "InitializeTransports", - ConnectTransport = "ConnectTransport", + InitializeTransports = "InitializeTransports", + ConnectTransport = "ConnectTransport", - StartProduce = "StartProduce", - StopProduce = "StopProduce", + StartProduce = "StartProduce", + StopProduce = "StopProduce", - StartConsume = "StartConsume", - StopConsume = "StopConsume", - SetConsumerPause = "SetConsumerPause" + StartConsume = "StartConsume", + StopConsume = "StopConsume", + SetConsumerPause = "SetConsumerPause", } export enum WSErrorCode { - NotConnected = 0, - NotFound = 404, + NotConnected = 0, + NotFound = 404, - TransportConnectionFailure = 601, + TransportConnectionFailure = 601, - ProducerFailure = 611, - ProducerNotFound = 614, + ProducerFailure = 611, + ProducerNotFound = 614, - ConsumerFailure = 621, - ConsumerNotFound = 624 + ConsumerFailure = 621, + ConsumerNotFound = 624, } export enum WSCloseCode { - // Sent when the received data is not a string, or is unparseable - InvalidData = 1003, - Unauthorized = 4001, - RoomClosed = 4004, - // Sent when a client tries to send an opcode in the wrong state - InvalidState = 1002, - ServerError = 1011 + // Sent when the received data is not a string, or is unparseable + InvalidData = 1003, + Unauthorized = 4001, + RoomClosed = 4004, + // Sent when a client tries to send an opcode in the wrong state + InvalidState = 1002, + ServerError = 1011, } export interface VoiceError { - error: WSErrorCode | WSCloseCode; - message: string; + error: WSErrorCode | WSCloseCode; + message: string; } export type ProduceType = "audio"; //| "video" | "saudio" | "svideo"; export interface AuthenticationResult { - userId: string; - roomId: string; - rtpCapabilities: RtpCapabilities; + userId: string; + roomId: string; + rtpCapabilities: RtpCapabilities; } export interface Room { - id: string; - videoAllowed: boolean; - users: Map<string, VoiceUser>; + id: string; + videoAllowed: boolean; + users: Map<string, VoiceUser>; } export interface VoiceUser { - audio?: boolean; - //video?: boolean, - //saudio?: boolean, - //svideo?: boolean, + audio?: boolean; + //video?: boolean, + //saudio?: boolean, + //svideo?: boolean, } export interface ConsumerList { - audio?: Consumer; - //video?: Consumer, - //saudio?: Consumer, - //svideo?: Consumer, + audio?: Consumer; + //video?: Consumer, + //saudio?: Consumer, + //svideo?: Consumer, } export interface TransportInitData { - id: string; - iceParameters: IceParameters; - iceCandidates: IceCandidate[]; - dtlsParameters: DtlsParameters; - sctpParameters: SctpParameters | undefined; + id: string; + iceParameters: IceParameters; + iceCandidates: IceCandidate[]; + dtlsParameters: DtlsParameters; + sctpParameters: SctpParameters | undefined; } export interface TransportInitDataTuple { - sendTransport: TransportInitData; - recvTransport: TransportInitData; + sendTransport: TransportInitData; + recvTransport: TransportInitData; } export interface ConsumerData { - id: string; - producerId: string; - kind: MediaKind; - rtpParameters: RtpParameters; + id: string; + producerId: string; + kind: MediaKind; + rtpParameters: RtpParameters; } diff --git a/src/lib/vortex/VoiceClient.ts b/src/lib/vortex/VoiceClient.ts index 903295bb3d44f8ef83ade8e6a27e55879c53ab3e..9ebeab60ea7ecb8fdc642426d7b7810f01e17551 100644 --- a/src/lib/vortex/VoiceClient.ts +++ b/src/lib/vortex/VoiceClient.ts @@ -1,331 +1,331 @@ import EventEmitter from "eventemitter3"; - import * as mediasoupClient from "mediasoup-client"; + import { - Device, - Producer, - Transport, - UnsupportedError + Device, + Producer, + Transport, + UnsupportedError, } from "mediasoup-client/lib/types"; +import Signaling from "./Signaling"; import { - ProduceType, - WSEventType, - VoiceError, - VoiceUser, - ConsumerList, - WSErrorCode + ProduceType, + WSEventType, + VoiceError, + VoiceUser, + ConsumerList, + WSErrorCode, } from "./Types"; -import Signaling from "./Signaling"; interface VoiceEvents { - ready: () => void; - error: (error: Error) => void; - close: (error?: VoiceError) => void; + ready: () => void; + error: (error: Error) => void; + close: (error?: VoiceError) => void; - startProduce: (type: ProduceType) => void; - stopProduce: (type: ProduceType) => void; + startProduce: (type: ProduceType) => void; + stopProduce: (type: ProduceType) => void; - userJoined: (userId: string) => void; - userLeft: (userId: string) => void; + userJoined: (userId: string) => void; + userLeft: (userId: string) => void; - userStartProduce: (userId: string, type: ProduceType) => void; - userStopProduce: (userId: string, type: ProduceType) => void; + userStartProduce: (userId: string, type: ProduceType) => void; + userStopProduce: (userId: string, type: ProduceType) => void; } export default class VoiceClient extends EventEmitter<VoiceEvents> { - private _supported: boolean; - - device?: Device; - signaling: Signaling; - - sendTransport?: Transport; - recvTransport?: Transport; - - userId?: string; - roomId?: string; - participants: Map<string, VoiceUser>; - consumers: Map<string, ConsumerList>; - - audioProducer?: Producer; - constructor() { - super(); - this._supported = mediasoupClient.detectDevice() !== undefined; - this.signaling = new Signaling(); - - this.participants = new Map(); - this.consumers = new Map(); - - this.signaling.on( - "data", - json => { - const data = json.data; - switch (json.type) { - case WSEventType.UserJoined: { - this.participants.set(data.id, {}); - this.emit("userJoined", data.id); - break; - } - case WSEventType.UserLeft: { - this.participants.delete(data.id); - this.emit("userLeft", data.id); - - if (this.recvTransport) this.stopConsume(data.id); - break; - } - case WSEventType.UserStartProduce: { - const user = this.participants.get(data.id); - if (user === undefined) return; - switch (data.type) { - case "audio": - user.audio = true; - break; - default: - throw new Error( - `Invalid produce type ${data.type}` - ); - } - - if (this.recvTransport) - this.startConsume(data.id, data.type); - this.emit("userStartProduce", data.id, data.type); - break; - } - case WSEventType.UserStopProduce: { - const user = this.participants.get(data.id); - if (user === undefined) return; - switch (data.type) { - case "audio": - user.audio = false; - break; - default: - throw new Error( - `Invalid produce type ${data.type}` - ); - } - - if (this.recvTransport) - this.stopConsume(data.id, data.type); - this.emit("userStopProduce", data.id, data.type); - break; - } - } - }, - this - ); - - this.signaling.on( - "error", - error => { - this.emit("error", new Error("Signaling error")); - }, - this - ); - - this.signaling.on( - "close", - error => { - this.disconnect( - { - error: error.code, - message: error.reason - }, - true - ); - }, - this - ); - } - - supported() { - return this._supported; - } - throwIfUnsupported() { - if (!this._supported) throw new UnsupportedError("RTC not supported"); - } - - connect(address: string, roomId: string) { - this.throwIfUnsupported(); - this.device = new Device(); - this.roomId = roomId; - return this.signaling.connect(address); - } - - disconnect(error?: VoiceError, ignoreDisconnected?: boolean) { - if (!this.signaling.connected() && !ignoreDisconnected) return; - this.signaling.disconnect(); - this.participants = new Map(); - this.consumers = new Map(); - this.userId = undefined; - this.roomId = undefined; - - this.audioProducer = undefined; - - if (this.sendTransport) this.sendTransport.close(); - if (this.recvTransport) this.recvTransport.close(); - this.sendTransport = undefined; - this.recvTransport = undefined; - - this.emit("close", error); - } - - async authenticate(token: string) { - this.throwIfUnsupported(); - if (this.device === undefined || this.roomId === undefined) - throw new ReferenceError("Voice Client is in an invalid state"); - const result = await this.signaling.authenticate(token, this.roomId); - let [room] = await Promise.all([ - this.signaling.roomInfo(), - this.device.load({ routerRtpCapabilities: result.rtpCapabilities }) - ]); - - this.userId = result.userId; - this.participants = room.users; - } - - async initializeTransports() { - this.throwIfUnsupported(); - if (this.device === undefined) - throw new ReferenceError("Voice Client is in an invalid state"); - const initData = await this.signaling.initializeTransports( - this.device.rtpCapabilities - ); - - this.sendTransport = this.device.createSendTransport( - initData.sendTransport - ); - this.recvTransport = this.device.createRecvTransport( - initData.recvTransport - ); - - const connectTransport = (transport: Transport) => { - transport.on("connect", ({ dtlsParameters }, callback, errback) => { - this.signaling - .connectTransport(transport.id, dtlsParameters) - .then(callback) - .catch(errback); - }); - }; - - connectTransport(this.sendTransport); - connectTransport(this.recvTransport); - - this.sendTransport.on("produce", (parameters, callback, errback) => { - const type = parameters.appData.type; - if ( - parameters.kind === "audio" && - type !== "audio" && - type !== "saudio" - ) - return errback(); - if ( - parameters.kind === "video" && - type !== "video" && - type !== "svideo" - ) - return errback(); - this.signaling - .startProduce(type, parameters.rtpParameters) - .then(id => callback({ id })) - .catch(errback); - }); - - this.emit("ready"); - for (let user of this.participants) { - if (user[1].audio && user[0] !== this.userId) - this.startConsume(user[0], "audio"); - } - } - - private async startConsume(userId: string, type: ProduceType) { - if (this.recvTransport === undefined) - throw new Error("Receive transport undefined"); - const consumers = this.consumers.get(userId) || {}; - const consumerParams = await this.signaling.startConsume(userId, type); - const consumer = await this.recvTransport.consume(consumerParams); - switch (type) { - case "audio": - consumers.audio = consumer; - } - - const mediaStream = new MediaStream([consumer.track]); - const audio = new Audio(); - audio.srcObject = mediaStream; - await this.signaling.setConsumerPause(consumer.id, false); - audio.play(); - this.consumers.set(userId, consumers); - } - - private async stopConsume(userId: string, type?: ProduceType) { - const consumers = this.consumers.get(userId); - if (consumers === undefined) return; - if (type === undefined) { - if (consumers.audio !== undefined) consumers.audio.close(); - this.consumers.delete(userId); - } else { - switch (type) { - case "audio": { - if (consumers.audio !== undefined) { - consumers.audio.close(); - this.signaling.stopConsume(consumers.audio.id); - } - consumers.audio = undefined; - break; - } - } - - this.consumers.set(userId, consumers); - } - } - - async startProduce(track: MediaStreamTrack, type: ProduceType) { - if (this.sendTransport === undefined) - throw new Error("Send transport undefined"); - const producer = await this.sendTransport.produce({ - track, - appData: { type } - }); - - switch (type) { - case "audio": - this.audioProducer = producer; - break; - } - - const participant = this.participants.get(this.userId || ""); - if (participant !== undefined) { - participant[type] = true; - this.participants.set(this.userId || "", participant); - } - - this.emit("startProduce", type); - } - - async stopProduce(type: ProduceType) { - let producer; - switch (type) { - case "audio": - producer = this.audioProducer; - this.audioProducer = undefined; - break; - } - - if (producer !== undefined) { - producer.close(); - this.emit("stopProduce", type); - } - - const participant = this.participants.get(this.userId || ""); - if (participant !== undefined) { - participant[type] = false; - this.participants.set(this.userId || "", participant); - } - - try { - await this.signaling.stopProduce(type); - } catch (error) { - if (error.error === WSErrorCode.ProducerNotFound) return; - else throw error; - } - } + private _supported: boolean; + + device?: Device; + signaling: Signaling; + + sendTransport?: Transport; + recvTransport?: Transport; + + userId?: string; + roomId?: string; + participants: Map<string, VoiceUser>; + consumers: Map<string, ConsumerList>; + + audioProducer?: Producer; + constructor() { + super(); + this._supported = mediasoupClient.detectDevice() !== undefined; + this.signaling = new Signaling(); + + this.participants = new Map(); + this.consumers = new Map(); + + this.signaling.on( + "data", + (json) => { + const data = json.data; + switch (json.type) { + case WSEventType.UserJoined: { + this.participants.set(data.id, {}); + this.emit("userJoined", data.id); + break; + } + case WSEventType.UserLeft: { + this.participants.delete(data.id); + this.emit("userLeft", data.id); + + if (this.recvTransport) this.stopConsume(data.id); + break; + } + case WSEventType.UserStartProduce: { + const user = this.participants.get(data.id); + if (user === undefined) return; + switch (data.type) { + case "audio": + user.audio = true; + break; + default: + throw new Error( + `Invalid produce type ${data.type}`, + ); + } + + if (this.recvTransport) + this.startConsume(data.id, data.type); + this.emit("userStartProduce", data.id, data.type); + break; + } + case WSEventType.UserStopProduce: { + const user = this.participants.get(data.id); + if (user === undefined) return; + switch (data.type) { + case "audio": + user.audio = false; + break; + default: + throw new Error( + `Invalid produce type ${data.type}`, + ); + } + + if (this.recvTransport) + this.stopConsume(data.id, data.type); + this.emit("userStopProduce", data.id, data.type); + break; + } + } + }, + this, + ); + + this.signaling.on( + "error", + (error) => { + this.emit("error", new Error("Signaling error")); + }, + this, + ); + + this.signaling.on( + "close", + (error) => { + this.disconnect( + { + error: error.code, + message: error.reason, + }, + true, + ); + }, + this, + ); + } + + supported() { + return this._supported; + } + throwIfUnsupported() { + if (!this._supported) throw new UnsupportedError("RTC not supported"); + } + + connect(address: string, roomId: string) { + this.throwIfUnsupported(); + this.device = new Device(); + this.roomId = roomId; + return this.signaling.connect(address); + } + + disconnect(error?: VoiceError, ignoreDisconnected?: boolean) { + if (!this.signaling.connected() && !ignoreDisconnected) return; + this.signaling.disconnect(); + this.participants = new Map(); + this.consumers = new Map(); + this.userId = undefined; + this.roomId = undefined; + + this.audioProducer = undefined; + + if (this.sendTransport) this.sendTransport.close(); + if (this.recvTransport) this.recvTransport.close(); + this.sendTransport = undefined; + this.recvTransport = undefined; + + this.emit("close", error); + } + + async authenticate(token: string) { + this.throwIfUnsupported(); + if (this.device === undefined || this.roomId === undefined) + throw new ReferenceError("Voice Client is in an invalid state"); + const result = await this.signaling.authenticate(token, this.roomId); + let [room] = await Promise.all([ + this.signaling.roomInfo(), + this.device.load({ routerRtpCapabilities: result.rtpCapabilities }), + ]); + + this.userId = result.userId; + this.participants = room.users; + } + + async initializeTransports() { + this.throwIfUnsupported(); + if (this.device === undefined) + throw new ReferenceError("Voice Client is in an invalid state"); + const initData = await this.signaling.initializeTransports( + this.device.rtpCapabilities, + ); + + this.sendTransport = this.device.createSendTransport( + initData.sendTransport, + ); + this.recvTransport = this.device.createRecvTransport( + initData.recvTransport, + ); + + const connectTransport = (transport: Transport) => { + transport.on("connect", ({ dtlsParameters }, callback, errback) => { + this.signaling + .connectTransport(transport.id, dtlsParameters) + .then(callback) + .catch(errback); + }); + }; + + connectTransport(this.sendTransport); + connectTransport(this.recvTransport); + + this.sendTransport.on("produce", (parameters, callback, errback) => { + const type = parameters.appData.type; + if ( + parameters.kind === "audio" && + type !== "audio" && + type !== "saudio" + ) + return errback(); + if ( + parameters.kind === "video" && + type !== "video" && + type !== "svideo" + ) + return errback(); + this.signaling + .startProduce(type, parameters.rtpParameters) + .then((id) => callback({ id })) + .catch(errback); + }); + + this.emit("ready"); + for (let user of this.participants) { + if (user[1].audio && user[0] !== this.userId) + this.startConsume(user[0], "audio"); + } + } + + private async startConsume(userId: string, type: ProduceType) { + if (this.recvTransport === undefined) + throw new Error("Receive transport undefined"); + const consumers = this.consumers.get(userId) || {}; + const consumerParams = await this.signaling.startConsume(userId, type); + const consumer = await this.recvTransport.consume(consumerParams); + switch (type) { + case "audio": + consumers.audio = consumer; + } + + const mediaStream = new MediaStream([consumer.track]); + const audio = new Audio(); + audio.srcObject = mediaStream; + await this.signaling.setConsumerPause(consumer.id, false); + audio.play(); + this.consumers.set(userId, consumers); + } + + private async stopConsume(userId: string, type?: ProduceType) { + const consumers = this.consumers.get(userId); + if (consumers === undefined) return; + if (type === undefined) { + if (consumers.audio !== undefined) consumers.audio.close(); + this.consumers.delete(userId); + } else { + switch (type) { + case "audio": { + if (consumers.audio !== undefined) { + consumers.audio.close(); + this.signaling.stopConsume(consumers.audio.id); + } + consumers.audio = undefined; + break; + } + } + + this.consumers.set(userId, consumers); + } + } + + async startProduce(track: MediaStreamTrack, type: ProduceType) { + if (this.sendTransport === undefined) + throw new Error("Send transport undefined"); + const producer = await this.sendTransport.produce({ + track, + appData: { type }, + }); + + switch (type) { + case "audio": + this.audioProducer = producer; + break; + } + + const participant = this.participants.get(this.userId || ""); + if (participant !== undefined) { + participant[type] = true; + this.participants.set(this.userId || "", participant); + } + + this.emit("startProduce", type); + } + + async stopProduce(type: ProduceType) { + let producer; + switch (type) { + case "audio": + producer = this.audioProducer; + this.audioProducer = undefined; + break; + } + + if (producer !== undefined) { + producer.close(); + this.emit("stopProduce", type); + } + + const participant = this.participants.get(this.userId || ""); + if (participant !== undefined) { + participant[type] = false; + this.participants.set(this.userId || "", participant); + } + + try { + await this.signaling.stopProduce(type); + } catch (error) { + if (error.error === WSErrorCode.ProducerNotFound) return; + else throw error; + } + } } diff --git a/src/lib/windowSize.ts b/src/lib/windowSize.ts index 55089b4e103ad3eda85ce0d2bec1493340b09382..770dcd6cfa64ad5da677383f5bbace0c6bcd50d4 100644 --- a/src/lib/windowSize.ts +++ b/src/lib/windowSize.ts @@ -1,24 +1,24 @@ import { useEffect, useState } from "preact/hooks"; export function useWindowSize() { - const [windowSize, setWindowSize] = useState({ - width: window.innerWidth, - height: window.innerHeight - }); + const [windowSize, setWindowSize] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }); - useEffect(() => { - function handleResize() { - setWindowSize({ - width: window.innerWidth, - height: window.innerHeight - }); - } + useEffect(() => { + function handleResize() { + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + } - window.addEventListener("resize", handleResize); - handleResize(); + window.addEventListener("resize", handleResize); + handleResize(); - return () => window.removeEventListener("resize", handleResize); - }, []); + return () => window.removeEventListener("resize", handleResize); + }, []); - return windowSize; + return windowSize; } diff --git a/src/main.tsx b/src/main.tsx index 02c4364bb37502b066c0f0c4e8ebd3ac1d32b330..c842979523e9a97c0ee9f61aa631cc49f5a245d3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,19 +1,21 @@ -import { registerSW } from 'virtual:pwa-register'; -import { internalEmit } from './lib/eventEmitter'; - -export const updateSW = registerSW({ - onNeedRefresh() { - internalEmit('PWA', 'update'); - }, - onOfflineReady() { - console.info('Ready to work offline.'); - // show a ready to work offline to user - }, -}) +import { registerSW } from "virtual:pwa-register"; import "./styles/index.scss"; import { render } from "preact"; + +import { internalEmit } from "./lib/eventEmitter"; + import { App } from "./pages/app"; +export const updateSW = registerSW({ + onNeedRefresh() { + internalEmit("PWA", "update"); + }, + onOfflineReady() { + console.info("Ready to work offline."); + // show a ready to work offline to user + }, +}); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion render(<App />, document.getElementById("app")!); diff --git a/src/pages/Open.tsx b/src/pages/Open.tsx index 7ee8120cfee956098ff60e9606faf0f4c9216f03..32a471c31b496a56fdee50247630e3d2d19ac06a 100644 --- a/src/pages/Open.tsx +++ b/src/pages/Open.tsx @@ -1,72 +1,83 @@ +import { useHistory, useParams } from "react-router-dom"; + import { Text } from "preact-i18n"; -import Header from "../components/ui/Header"; import { useContext, useEffect } from "preact/hooks"; -import { useHistory, useParams } from "react-router-dom"; + import { useIntermediate } from "../context/intermediate/Intermediate"; -import { useChannels, useForceUpdate, useUser } from "../context/revoltjs/hooks"; -import { AppContext, ClientStatus, StatusContext } from "../context/revoltjs/RevoltClient"; +import { + AppContext, + ClientStatus, + StatusContext, +} from "../context/revoltjs/RevoltClient"; +import { + useChannels, + useForceUpdate, + useUser, +} from "../context/revoltjs/hooks"; + +import Header from "../components/ui/Header"; export default function Open() { - const history = useHistory(); - const client = useContext(AppContext); - const status = useContext(StatusContext); - const { id } = useParams<{ id: string }>(); - const { openScreen } = useIntermediate(); + const history = useHistory(); + const client = useContext(AppContext); + const status = useContext(StatusContext); + const { id } = useParams<{ id: string }>(); + const { openScreen } = useIntermediate(); - if (status !== ClientStatus.ONLINE) { - return ( - <Header placement="primary"> - <Text id="general.loading" /> - </Header> - ); - } + if (status !== ClientStatus.ONLINE) { + return ( + <Header placement="primary"> + <Text id="general.loading" /> + </Header> + ); + } - const ctx = useForceUpdate(); - const channels = useChannels(undefined, ctx); - const user = useUser(id, ctx); + const ctx = useForceUpdate(); + const channels = useChannels(undefined, ctx); + const user = useUser(id, ctx); - useEffect(() => { - if (id === "saved") { - for (const channel of channels) { - if (channel?.channel_type === "SavedMessages") { - history.push(`/channel/${channel._id}`); - return; - } - } + useEffect(() => { + if (id === "saved") { + for (const channel of channels) { + if (channel?.channel_type === "SavedMessages") { + history.push(`/channel/${channel._id}`); + return; + } + } - client.users - .openDM(client.user?._id as string) - .then(channel => history.push(`/channel/${channel?._id}`)) - .catch(error => openScreen({ id: "error", error })); + client.users + .openDM(client.user?._id as string) + .then((channel) => history.push(`/channel/${channel?._id}`)) + .catch((error) => openScreen({ id: "error", error })); - return; - } + return; + } - if (user) { - const channel: string | undefined = channels.find( - channel => - channel?.channel_type === "DirectMessage" && - channel.recipients.includes(id) - )?._id; + if (user) { + const channel: string | undefined = channels.find( + (channel) => + channel?.channel_type === "DirectMessage" && + channel.recipients.includes(id), + )?._id; - if (channel) { - history.push(`/channel/${channel}`); - } else { - client.users - .openDM(id) - .then(channel => history.push(`/channel/${channel?._id}`)) - .catch(error => openScreen({ id: "error", error })); - } + if (channel) { + history.push(`/channel/${channel}`); + } else { + client.users + .openDM(id) + .then((channel) => history.push(`/channel/${channel?._id}`)) + .catch((error) => openScreen({ id: "error", error })); + } - return; - } + return; + } - history.push("/"); - }, []); + history.push("/"); + }, []); - return ( - <Header placement="primary"> - <Text id="general.loading" /> - </Header> - ); + return ( + <Header placement="primary"> + <Text id="general.loading" /> + </Header> + ); } diff --git a/src/pages/RevoltApp.tsx b/src/pages/RevoltApp.tsx index 9b8cbcd66e9e8ba04684ec9ae3766ffec83e8556..da941deef896279370fd3fc8d7603a7b99eea3ff 100644 --- a/src/pages/RevoltApp.tsx +++ b/src/pages/RevoltApp.tsx @@ -1,84 +1,120 @@ import { Docked, OverlappingPanels, ShowIf } from "react-overlapping-panels"; -import { isTouchscreenDevice } from "../lib/isTouchscreenDevice"; import { Switch, Route, useLocation } from "react-router-dom"; import styled from "styled-components"; import ContextMenus from "../lib/ContextMenus"; +import { isTouchscreenDevice } from "../lib/isTouchscreenDevice"; + import Popovers from "../context/intermediate/Popovers"; -import SyncManager from "../context/revoltjs/SyncManager"; -import StateMonitor from "../context/revoltjs/StateMonitor"; import Notifications from "../context/revoltjs/Notifications"; +import StateMonitor from "../context/revoltjs/StateMonitor"; +import SyncManager from "../context/revoltjs/SyncManager"; +import BottomNavigation from "../components/navigation/BottomNavigation"; import LeftSidebar from "../components/navigation/LeftSidebar"; import RightSidebar from "../components/navigation/RightSidebar"; -import BottomNavigation from "../components/navigation/BottomNavigation"; - import Open from "./Open"; -import Home from './home/Home'; -import Invite from "./invite/Invite"; -import Friends from "./friends/Friends"; import Channel from "./channels/Channel"; -import Settings from './settings/Settings'; import Developer from "./developer/Developer"; -import ServerSettings from "./settings/ServerSettings"; +import Friends from "./friends/Friends"; +import Home from "./home/Home"; +import Invite from "./invite/Invite"; import ChannelSettings from "./settings/ChannelSettings"; +import ServerSettings from "./settings/ServerSettings"; +import Settings from "./settings/Settings"; const Routes = styled.div` - min-width: 0; - display: flex; - overflow: hidden; - flex-direction: column; - background: var(--primary-background); + min-width: 0; + display: flex; + overflow: hidden; + flex-direction: column; + background: var(--primary-background); `; export default function App() { - const path = useLocation().pathname; - const fixedBottomNav = (path === '/' || path === '/settings' || path.startsWith("/friends")); - const inSettings = path.includes('/settings'); - const inChannel = path.includes('/channel'); - const inSpecial = (path.startsWith("/friends") && isTouchscreenDevice) || path.startsWith('/invite') || path.startsWith("/settings"); + const path = useLocation().pathname; + const fixedBottomNav = + path === "/" || path === "/settings" || path.startsWith("/friends"); + const inSettings = path.includes("/settings"); + const inChannel = path.includes("/channel"); + const inSpecial = + (path.startsWith("/friends") && isTouchscreenDevice) || + path.startsWith("/invite") || + path.startsWith("/settings"); + + return ( + <OverlappingPanels + width="100vw" + height="var(--app-height)" + leftPanel={ + inSpecial + ? undefined + : { width: 292, component: <LeftSidebar /> } + } + rightPanel={ + !inSettings && inChannel + ? { width: 240, component: <RightSidebar /> } + : undefined + } + bottomNav={{ + component: <BottomNavigation />, + showIf: fixedBottomNav ? ShowIf.Always : ShowIf.Left, + height: 50, + }} + docked={isTouchscreenDevice ? Docked.None : Docked.Left}> + <Routes> + <Switch> + <Route + path="/server/:server/channel/:channel/settings/:page" + component={ChannelSettings} + /> + <Route + path="/server/:server/channel/:channel/settings" + component={ChannelSettings} + /> + <Route + path="/server/:server/settings/:page" + component={ServerSettings} + /> + <Route + path="/server/:server/settings" + component={ServerSettings} + /> + <Route + path="/channel/:channel/settings/:page" + component={ChannelSettings} + /> + <Route + path="/channel/:channel/settings" + component={ChannelSettings} + /> - return ( - <OverlappingPanels - width="100vw" - height="var(--app-height)" - leftPanel={inSpecial ? undefined : { width: 292, component: <LeftSidebar /> }} - rightPanel={(!inSettings && inChannel) ? { width: 240, component: <RightSidebar /> } : undefined} - bottomNav={{ - component: <BottomNavigation />, - showIf: fixedBottomNav ? ShowIf.Always : ShowIf.Left, - height: 50 - }} - docked={isTouchscreenDevice ? Docked.None : Docked.Left}> - <Routes> - <Switch> - <Route path="/server/:server/channel/:channel/settings/:page" component={ChannelSettings} /> - <Route path="/server/:server/channel/:channel/settings" component={ChannelSettings} /> - <Route path="/server/:server/settings/:page" component={ServerSettings} /> - <Route path="/server/:server/settings" component={ServerSettings} /> - <Route path="/channel/:channel/settings/:page" component={ChannelSettings} /> - <Route path="/channel/:channel/settings" component={ChannelSettings} /> + <Route + path="/channel/:channel/message/:message" + component={Channel} + /> + <Route + path="/server/:server/channel/:channel" + component={Channel} + /> + <Route path="/server/:server" /> + <Route path="/channel/:channel" component={Channel} /> - <Route path="/channel/:channel/message/:message" component={Channel} /> - <Route path="/server/:server/channel/:channel" component={Channel} /> - <Route path="/server/:server" /> - <Route path="/channel/:channel" component={Channel} /> - - <Route path="/settings/:page" component={Settings} /> - <Route path="/settings" component={Settings} /> + <Route path="/settings/:page" component={Settings} /> + <Route path="/settings" component={Settings} /> - <Route path="/dev" component={Developer} /> - <Route path="/friends" component={Friends} /> - <Route path="/open/:id" component={Open} /> - <Route path="/invite/:code" component={Invite} /> - <Route path="/" component={Home} /> - </Switch> - </Routes> - <ContextMenus /> - <Popovers /> - <Notifications /> - <StateMonitor /> - <SyncManager /> - </OverlappingPanels> - ); -}; + <Route path="/dev" component={Developer} /> + <Route path="/friends" component={Friends} /> + <Route path="/open/:id" component={Open} /> + <Route path="/invite/:code" component={Invite} /> + <Route path="/" component={Home} /> + </Switch> + </Routes> + <ContextMenus /> + <Popovers /> + <Notifications /> + <StateMonitor /> + <SyncManager /> + </OverlappingPanels> + ); +} diff --git a/src/pages/app.tsx b/src/pages/app.tsx index 9e1d0482a63e61713db16fca28711b234b21575d..d5a23a47d42446df9f33c3a0f123c8627a02dea4 100644 --- a/src/pages/app.tsx +++ b/src/pages/app.tsx @@ -1,33 +1,36 @@ -import { CheckAuth } from "../context/revoltjs/CheckAuth"; -import Preloader from "../components/ui/Preloader"; import { Route, Switch } from "react-router-dom"; -import Masks from "../components/ui/Masks"; -import Context from "../context"; import { lazy, Suspense } from "preact/compat"; -const Login = lazy(() => import('./login/Login')); -const RevoltApp = lazy(() => import('./RevoltApp')); + +import Context from "../context"; +import { CheckAuth } from "../context/revoltjs/CheckAuth"; + +import Masks from "../components/ui/Masks"; +import Preloader from "../components/ui/Preloader"; + +const Login = lazy(() => import("./login/Login")); +const RevoltApp = lazy(() => import("./RevoltApp")); export function App() { - return ( - <Context> - <Masks /> - {/* + return ( + <Context> + <Masks /> + {/* // @ts-expect-error */} - <Suspense fallback={<Preloader type="spinner" />}> - <Switch> - <Route path="/login"> - <CheckAuth> - <Login /> - </CheckAuth> - </Route> - <Route path="/"> - <CheckAuth auth> - <RevoltApp /> - </CheckAuth> - </Route> - </Switch> - </Suspense> - </Context> - ); + <Suspense fallback={<Preloader type="spinner" />}> + <Switch> + <Route path="/login"> + <CheckAuth> + <Login /> + </CheckAuth> + </Route> + <Route path="/"> + <CheckAuth auth> + <RevoltApp /> + </CheckAuth> + </Route> + </Switch> + </Suspense> + </Context> + ); } diff --git a/src/pages/channels/Channel.tsx b/src/pages/channels/Channel.tsx index 6daf10963c17bdab23256931a3100d8d36e0601a..0b4c9129e8f8dfb645d14fc30bff527d65ba3a2d 100644 --- a/src/pages/channels/Channel.tsx +++ b/src/pages/channels/Channel.tsx @@ -1,122 +1,153 @@ -import styled from "styled-components"; -import { useEffect, useState } from "preact/hooks"; -import ChannelHeader from "./ChannelHeader"; import { useParams, useHistory } from "react-router-dom"; -import { MessageArea } from "./messaging/MessageArea"; -import Checkbox from "../../components/ui/Checkbox"; -import Button from "../../components/ui/Button"; -// import { useRenderState } from "../../lib/renderer/Singleton"; +import { Channels } from "revolt.js/dist/api/objects"; +import styled from "styled-components"; + +import { useState } from "preact/hooks"; + import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; -import MessageBox from "../../components/common/messaging/MessageBox"; + import { useChannel, useForceUpdate } from "../../context/revoltjs/hooks"; -import MemberSidebar from "../../components/navigation/right/MemberSidebar"; + +import MessageBox from "../../components/common/messaging/MessageBox"; import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom"; import TypingIndicator from "../../components/common/messaging/bars/TypingIndicator"; -import { Channel } from "revolt.js"; +import Button from "../../components/ui/Button"; +import Checkbox from "../../components/ui/Checkbox"; + +import MemberSidebar from "../../components/navigation/right/MemberSidebar"; +import ChannelHeader from "./ChannelHeader"; +import { MessageArea } from "./messaging/MessageArea"; import VoiceHeader from "./voice/VoiceHeader"; const ChannelMain = styled.div` - flex-grow: 1; - display: flex; - min-height: 0; - overflow: hidden; - flex-direction: row; + flex-grow: 1; + display: flex; + min-height: 0; + overflow: hidden; + flex-direction: row; `; const ChannelContent = styled.div` - flex-grow: 1; - display: flex; - overflow: hidden; - flex-direction: column; + flex-grow: 1; + display: flex; + overflow: hidden; + flex-direction: column; `; const AgeGate = styled.div` - display: flex; - flex-grow: 1; - flex-direction: column; - align-items: center; - justify-content: center; - user-select: none; - padding: 12px; - - img { - height: 150px; - } - - .subtext { - color: var(--secondary-foreground); - margin-bottom: 12px; - font-size: 14px; - } - - .actions { - margin-top: 20px; - display: flex; - gap: 12px; - } + display: flex; + flex-grow: 1; + flex-direction: column; + align-items: center; + justify-content: center; + user-select: none; + padding: 12px; + + img { + height: 150px; + } + + .subtext { + color: var(--secondary-foreground); + margin-bottom: 12px; + font-size: 14px; + } + + .actions { + margin-top: 20px; + display: flex; + gap: 12px; + } `; export function Channel({ id }: { id: string }) { - const ctx = useForceUpdate(); - const channel = useChannel(id, ctx); + const ctx = useForceUpdate(); + const channel = useChannel(id, ctx); - if (!channel) return null; + if (!channel) return null; - if (channel.channel_type === 'VoiceChannel') { - return <VoiceChannel channel={channel} />; - } else { - return <TextChannel channel={channel} />; - } + if (channel.channel_type === "VoiceChannel") { + return <VoiceChannel channel={channel} />; + } else { + return <TextChannel channel={channel} />; + } } -function TextChannel({ channel }: { channel: Channel }) { - const [ showMembers, setMembers ] = useState(true); - - if ((channel.channel_type === 'TextChannel' || channel.channel_type === 'Group') && channel.name.includes('nsfw')) { - const goBack = useHistory(); - const [ consent, setConsent ] = useState(false); - const [ ageGate, setAgeGate ] = useState(false); - if (!ageGate) { - return ( - <AgeGate> - <img src={"https://static.revolt.chat/emoji/mutant/26a0.svg"} draggable={false}/> - <h2>{channel.name}</h2> - <span className="subtext">This channel is marked as NSFW. <a href="#">Learn more</a></span> - - <Checkbox checked={consent} onChange={v => setConsent(v)}>I confirm that I am at least 18 years old.</Checkbox> - <div className="actions"> - <Button contrast onClick={() => goBack}>Go back</Button> - <Button contrast onClick={() => consent && setAgeGate(true)}>Enter Channel</Button> - </div> - </AgeGate> - ) - } - } - - let id = channel._id; - return <> - <ChannelHeader channel={channel} toggleSidebar={() => setMembers(!showMembers)} /> - <ChannelMain> - <ChannelContent> - <VoiceHeader id={id} /> - <MessageArea id={id} /> - <TypingIndicator id={id} /> - <JumpToBottom id={id} /> - <MessageBox channel={channel} /> - </ChannelContent> - { !isTouchscreenDevice && showMembers && <MemberSidebar channel={channel} /> } - </ChannelMain> - </>; +function TextChannel({ channel }: { channel: Channels.Channel }) { + const [showMembers, setMembers] = useState(true); + + if ( + (channel.channel_type === "TextChannel" || + channel.channel_type === "Group") && + channel.name.includes("nsfw") + ) { + const goBack = useHistory(); + const [consent, setConsent] = useState(false); + const [ageGate, setAgeGate] = useState(false); + if (!ageGate) { + return ( + <AgeGate> + <img + src={"https://static.revolt.chat/emoji/mutant/26a0.svg"} + draggable={false} + /> + <h2>{channel.name}</h2> + <span className="subtext"> + This channel is marked as NSFW.{" "} + <a href="#">Learn more</a> + </span> + + <Checkbox checked={consent} onChange={(v) => setConsent(v)}> + I confirm that I am at least 18 years old. + </Checkbox> + <div className="actions"> + <Button contrast onClick={() => goBack}> + Go back + </Button> + <Button + contrast + onClick={() => consent && setAgeGate(true)}> + Enter Channel + </Button> + </div> + </AgeGate> + ); + } + } + + let id = channel._id; + return ( + <> + <ChannelHeader + channel={channel} + toggleSidebar={() => setMembers(!showMembers)} + /> + <ChannelMain> + <ChannelContent> + <VoiceHeader id={id} /> + <MessageArea id={id} /> + <TypingIndicator id={id} /> + <JumpToBottom id={id} /> + <MessageBox channel={channel} /> + </ChannelContent> + {!isTouchscreenDevice && showMembers && ( + <MemberSidebar channel={channel} /> + )} + </ChannelMain> + </> + ); } -function VoiceChannel({ channel }: { channel: Channel }) { - return <> - <ChannelHeader channel={channel} /> - <VoiceHeader id={channel._id} /> - </>; +function VoiceChannel({ channel }: { channel: Channels.Channel }) { + return ( + <> + <ChannelHeader channel={channel} /> + <VoiceHeader id={channel._id} /> + </> + ); } -export default function() { - const { channel } = useParams<{ channel: string }>(); - return <Channel id={channel} key={channel} />; +export default function () { + const { channel } = useParams<{ channel: string }>(); + return <Channel id={channel} key={channel} />; } diff --git a/src/pages/channels/ChannelHeader.tsx b/src/pages/channels/ChannelHeader.tsx index 5dae6a04de8ada269e3c29d64b700a4d27aa6fbc..7a0736948c6dd3ee19cbda85a354ed25863abfe0 100644 --- a/src/pages/channels/ChannelHeader.tsx +++ b/src/pages/channels/ChannelHeader.tsx @@ -1,116 +1,139 @@ -import styled from "styled-components"; +import { At, Hash } from "@styled-icons/boxicons-regular"; +import { Notepad, Group } from "@styled-icons/boxicons-solid"; import { Channel, User } from "revolt.js"; +import styled from "styled-components"; + import { useContext } from "preact/hooks"; -import Header from "../../components/ui/Header"; -import HeaderActions from "./actions/HeaderActions"; -import Markdown from "../../components/markdown/Markdown"; -import { getChannelName } from "../../context/revoltjs/util"; -import UserStatus from "../../components/common/user/UserStatus"; + +import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; + +import { useIntermediate } from "../../context/intermediate/Intermediate"; import { AppContext } from "../../context/revoltjs/RevoltClient"; -import { At, Hash } from "@styled-icons/boxicons-regular"; -import { Notepad, Group } from "@styled-icons/boxicons-solid"; +import { getChannelName } from "../../context/revoltjs/util"; + import { useStatusColour } from "../../components/common/user/UserIcon"; -import { useIntermediate } from "../../context/intermediate/Intermediate"; -import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; +import UserStatus from "../../components/common/user/UserStatus"; +import Header from "../../components/ui/Header"; + +import Markdown from "../../components/markdown/Markdown"; +import HeaderActions from "./actions/HeaderActions"; export interface ChannelHeaderProps { - channel: Channel, - toggleSidebar?: () => void + channel: Channel; + toggleSidebar?: () => void; } const Info = styled.div` - flex-grow: 1; - min-width: 0; - overflow: hidden; - white-space: nowrap; + flex-grow: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; - display: flex; - gap: 8px; - align-items: center; + display: flex; + gap: 8px; + align-items: center; - * { - display: inline-block; - } + * { + display: inline-block; + } - .divider { - height: 20px; - margin: 0 5px; - padding-left: 1px; - background-color: var(--tertiary-background); - } + .divider { + height: 20px; + margin: 0 5px; + padding-left: 1px; + background-color: var(--tertiary-background); + } - .status { - width: 10px; - height: 10px; - border-radius: 50%; - display: inline-block; - margin-inline-end: 6px; - } + .status { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + margin-inline-end: 6px; + } - .desc { - cursor: pointer; - margin-top: 2px; - font-size: 0.8em; - font-weight: 400; - color: var(--secondary-foreground); - } + .desc { + cursor: pointer; + margin-top: 2px; + font-size: 0.8em; + font-weight: 400; + color: var(--secondary-foreground); + } `; -export default function ChannelHeader({ channel, toggleSidebar }: ChannelHeaderProps) { - const { openScreen } = useIntermediate(); - const client = useContext(AppContext); +export default function ChannelHeader({ + channel, + toggleSidebar, +}: ChannelHeaderProps) { + const { openScreen } = useIntermediate(); + const client = useContext(AppContext); - const name = getChannelName(client, channel); - let icon, recipient; - switch (channel.channel_type) { - case "SavedMessages": - icon = <Notepad size={24} />; - break; - case "DirectMessage": - icon = <At size={24} />; - const uid = client.channels.getRecipient(channel._id); - recipient = client.users.get(uid); - break; - case "Group": - icon = <Group size={24} />; - break; - case "TextChannel": - icon = <Hash size={24} />; - break; - } + const name = getChannelName(client, channel); + let icon, recipient; + switch (channel.channel_type) { + case "SavedMessages": + icon = <Notepad size={24} />; + break; + case "DirectMessage": + icon = <At size={24} />; + const uid = client.channels.getRecipient(channel._id); + recipient = client.users.get(uid); + break; + case "Group": + icon = <Group size={24} />; + break; + case "TextChannel": + icon = <Hash size={24} />; + break; + } - return ( - <Header placement="primary"> - { icon } - <Info> - <span className="name">{ name }</span> - {isTouchscreenDevice && channel.channel_type === "DirectMessage" && ( - <> - <div className="divider" /> - <span className="desc"> - <div className="status" style={{ backgroundColor: useStatusColour(recipient as User) }} /> - <UserStatus user={recipient as User} /> - </span> - </> - )} - {!isTouchscreenDevice && (channel.channel_type === "Group" || channel.channel_type === "TextChannel") && channel.description && ( - <> - <div className="divider" /> - <span - className="desc" - onClick={() => - openScreen({ - id: "channel_info", - channel_id: channel._id - }) - }> - - <Markdown content={channel.description.split("\n")[0] ?? ""} disallowBigEmoji /> - </span> - </> - )} - </Info> - <HeaderActions channel={channel} toggleSidebar={toggleSidebar} /> - </Header> - ) + return ( + <Header placement="primary"> + {icon} + <Info> + <span className="name">{name}</span> + {isTouchscreenDevice && + channel.channel_type === "DirectMessage" && ( + <> + <div className="divider" /> + <span className="desc"> + <div + className="status" + style={{ + backgroundColor: useStatusColour( + recipient as User, + ), + }} + /> + <UserStatus user={recipient as User} /> + </span> + </> + )} + {!isTouchscreenDevice && + (channel.channel_type === "Group" || + channel.channel_type === "TextChannel") && + channel.description && ( + <> + <div className="divider" /> + <span + className="desc" + onClick={() => + openScreen({ + id: "channel_info", + channel_id: channel._id, + }) + }> + <Markdown + content={ + channel.description.split("\n")[0] ?? "" + } + disallowBigEmoji + /> + </span> + </> + )} + </Info> + <HeaderActions channel={channel} toggleSidebar={toggleSidebar} /> + </Header> + ); } diff --git a/src/pages/channels/actions/HeaderActions.tsx b/src/pages/channels/actions/HeaderActions.tsx index 881ea0dd3492c3abca81b2710bdda12e43295fd6..096835db5088bf0757035fb523a7c38d97aa31ee 100644 --- a/src/pages/channels/actions/HeaderActions.tsx +++ b/src/pages/channels/actions/HeaderActions.tsx @@ -1,81 +1,112 @@ -import { useContext } from "preact/hooks"; +import { Sidebar as SidebarIcon } from "@styled-icons/boxicons-regular"; +import { + UserPlus, + Cog, + PhoneCall, + PhoneOutgoing, +} from "@styled-icons/boxicons-solid"; import { useHistory } from "react-router-dom"; -import { ChannelHeaderProps } from "../ChannelHeader"; -import IconButton from "../../../components/ui/IconButton"; -import { AppContext } from "../../../context/revoltjs/RevoltClient"; + +import { useContext } from "preact/hooks"; + import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; -import UpdateIndicator from "../../../components/common/UpdateIndicator"; + +import { + VoiceContext, + VoiceOperationsContext, + VoiceStatus, +} from "../../../context/Voice"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; -import { VoiceContext, VoiceOperationsContext, VoiceStatus } from "../../../context/Voice"; -import { UserPlus, Cog, PhoneCall, PhoneOutgoing } from "@styled-icons/boxicons-solid"; -import { Sidebar as SidebarIcon } from "@styled-icons/boxicons-regular"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; + +import UpdateIndicator from "../../../components/common/UpdateIndicator"; +import IconButton from "../../../components/ui/IconButton"; + +import { ChannelHeaderProps } from "../ChannelHeader"; -export default function HeaderActions({ channel, toggleSidebar }: ChannelHeaderProps) { - const { openScreen } = useIntermediate(); - const client = useContext(AppContext); - const history = useHistory(); +export default function HeaderActions({ + channel, + toggleSidebar, +}: ChannelHeaderProps) { + const { openScreen } = useIntermediate(); + const client = useContext(AppContext); + const history = useHistory(); - return ( - <> - <UpdateIndicator /> - { channel.channel_type === "Group" && ( - <> - <IconButton onClick={() => - openScreen({ - id: "user_picker", - omit: channel.recipients, - callback: async users => { - for (const user of users) { - await client.channels.addMember(channel._id, user); - } - } - })}> - <UserPlus size={27} /> - </IconButton> - <IconButton onClick={() => history.push(`/channel/${channel._id}/settings`)}> - <Cog size={24} /> - </IconButton> - </> - ) } - <VoiceActions channel={channel} /> - { (channel.channel_type === "Group" || channel.channel_type === "TextChannel") && !isTouchscreenDevice && ( - <IconButton onClick={toggleSidebar}> - <SidebarIcon size={22} /> - </IconButton> - ) } - </> - ) + return ( + <> + <UpdateIndicator /> + {channel.channel_type === "Group" && ( + <> + <IconButton + onClick={() => + openScreen({ + id: "user_picker", + omit: channel.recipients, + callback: async (users) => { + for (const user of users) { + await client.channels.addMember( + channel._id, + user, + ); + } + }, + }) + }> + <UserPlus size={27} /> + </IconButton> + <IconButton + onClick={() => + history.push(`/channel/${channel._id}/settings`) + }> + <Cog size={24} /> + </IconButton> + </> + )} + <VoiceActions channel={channel} /> + {(channel.channel_type === "Group" || + channel.channel_type === "TextChannel") && + !isTouchscreenDevice && ( + <IconButton onClick={toggleSidebar}> + <SidebarIcon size={22} /> + </IconButton> + )} + </> + ); } -function VoiceActions({ channel }: Pick<ChannelHeaderProps, 'channel'>) { - if (channel.channel_type === 'SavedMessages' || - channel.channel_type === 'TextChannel') return null; +function VoiceActions({ channel }: Pick<ChannelHeaderProps, "channel">) { + if ( + channel.channel_type === "SavedMessages" || + channel.channel_type === "TextChannel" + ) + return null; - const voice = useContext(VoiceContext); - const { connect, disconnect } = useContext(VoiceOperationsContext); + const voice = useContext(VoiceContext); + const { connect, disconnect } = useContext(VoiceOperationsContext); - if (voice.status >= VoiceStatus.READY) { - if (voice.roomId === channel._id) { - return ( - <IconButton onClick={disconnect}> - <PhoneOutgoing size={22} /> - </IconButton> - ) - } else { - return ( - <IconButton onClick={() => { - disconnect(); - connect(channel._id); - }}> - <PhoneCall size={24} /> - </IconButton> - ) - } - } else { - return ( - <IconButton> - <PhoneCall size={24} /** ! FIXME: TEMP */ color="red" /> - </IconButton> - ) - } + if (voice.status >= VoiceStatus.READY) { + if (voice.roomId === channel._id) { + return ( + <IconButton onClick={disconnect}> + <PhoneOutgoing size={22} /> + </IconButton> + ); + } else { + return ( + <IconButton + onClick={() => { + disconnect(); + connect(channel._id); + }}> + <PhoneCall size={24} /> + </IconButton> + ); + } + } else { + return ( + <IconButton> + <PhoneCall size={24} /** ! FIXME: TEMP */ color="red" /> + </IconButton> + ); + } } diff --git a/src/pages/channels/messaging/ConversationStart.tsx b/src/pages/channels/messaging/ConversationStart.tsx index 21ecadb12b4372707919d2414f781536ebfd2c7f..9fe82d7004a787d7fd8edafee083716b462e0be2 100644 --- a/src/pages/channels/messaging/ConversationStart.tsx +++ b/src/pages/channels/messaging/ConversationStart.tsx @@ -1,38 +1,40 @@ -import { Text } from "preact-i18n"; import styled from "styled-components"; -import { getChannelName } from "../../../context/revoltjs/util"; + +import { Text } from "preact-i18n"; + import { useChannel, useForceUpdate } from "../../../context/revoltjs/hooks"; +import { getChannelName } from "../../../context/revoltjs/util"; const StartBase = styled.div` - margin: 18px 16px 10px 16px; - - h1 { - font-size: 23px; - margin: 0 0 8px 0; - } - - h4 { - font-weight: 400; - margin: 0; - font-size: 14px; - } + margin: 18px 16px 10px 16px; + + h1 { + font-size: 23px; + margin: 0 0 8px 0; + } + + h4 { + font-weight: 400; + margin: 0; + font-size: 14px; + } `; interface Props { - id: string; + id: string; } export default function ConversationStart({ id }: Props) { - const ctx = useForceUpdate(); - const channel = useChannel(id, ctx); - if (!channel) return null; - - return ( - <StartBase> - <h1>{ getChannelName(ctx.client, channel, true) }</h1> - <h4> - <Text id="app.main.channel.start.group" /> - </h4> - </StartBase> - ); + const ctx = useForceUpdate(); + const channel = useChannel(id, ctx); + if (!channel) return null; + + return ( + <StartBase> + <h1>{getChannelName(ctx.client, channel, true)}</h1> + <h4> + <Text id="app.main.channel.start.group" /> + </h4> + </StartBase> + ); } diff --git a/src/pages/channels/messaging/MessageArea.tsx b/src/pages/channels/messaging/MessageArea.tsx index f69ae67800c077a5baad8e2c1b257a9b291af418..7fe720d15191a24857a637c586c3e939287b1326 100644 --- a/src/pages/channels/messaging/MessageArea.tsx +++ b/src/pages/channels/messaging/MessageArea.tsx @@ -1,232 +1,259 @@ -import styled from "styled-components"; -import { createContext } from "preact"; import { animateScroll } from "react-scroll"; -import MessageRenderer from "./MessageRenderer"; -import ConversationStart from './ConversationStart'; +import styled from "styled-components"; import useResizeObserver from "use-resize-observer"; -import Preloader from "../../../components/ui/Preloader"; -import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; -import { RenderState, ScrollState } from "../../../lib/renderer/types"; -import { SingletonMessageRenderer } from "../../../lib/renderer/Singleton"; -import { IntermediateContext } from "../../../context/intermediate/Intermediate"; -import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; -import { useContext, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; + +import { createContext } from "preact"; +import { + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "preact/hooks"; + import { defer } from "../../../lib/defer"; import { internalEmit } from "../../../lib/eventEmitter"; +import { SingletonMessageRenderer } from "../../../lib/renderer/Singleton"; +import { RenderState, ScrollState } from "../../../lib/renderer/types"; + +import { IntermediateContext } from "../../../context/intermediate/Intermediate"; +import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; +import { + ClientStatus, + StatusContext, +} from "../../../context/revoltjs/RevoltClient"; + +import Preloader from "../../../components/ui/Preloader"; + +import ConversationStart from "./ConversationStart"; +import MessageRenderer from "./MessageRenderer"; const Area = styled.div` - height: 100%; - flex-grow: 1; - min-height: 0; - overflow-x: hidden; - overflow-y: scroll; - word-break: break-word; - - > div { - display: flex; - min-height: 100%; - padding-bottom: 20px; - flex-direction: column; - justify-content: flex-end; - } + height: 100%; + flex-grow: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: scroll; + word-break: break-word; + + > div { + display: flex; + min-height: 100%; + padding-bottom: 20px; + flex-direction: column; + justify-content: flex-end; + } `; interface Props { - id: string; + id: string; } export const MessageAreaWidthContext = createContext(0); export const MESSAGE_AREA_PADDING = 82; export function MessageArea({ id }: Props) { - const status = useContext(StatusContext); - const { focusTaken } = useContext(IntermediateContext); - - // ? This is the scroll container. - const ref = useRef<HTMLDivElement>(null); - const { width, height } = useResizeObserver<HTMLDivElement>({ ref }); - - // ? Current channel state. - const [state, setState] = useState<RenderState>({ type: "LOADING" }); - - // ? useRef to avoid re-renders - const scrollState = useRef<ScrollState>({ type: "Free" }); - - const setScrollState = (v: ScrollState) => { - if (v.type === 'StayAtBottom') { - if (scrollState.current.type === 'Bottom' || atBottom()) { - scrollState.current = { type: 'ScrollToBottom', smooth: v.smooth }; - } else { - scrollState.current = { type: 'Free' }; - } - } else { - scrollState.current = v; - } - - defer(() => { - if (scrollState.current.type === "ScrollToBottom") { - setScrollState({ type: "Bottom", scrollingUntil: + new Date() + 150 }); - - animateScroll.scrollToBottom({ - container: ref.current, - duration: scrollState.current.smooth ? 150 : 0 - }); - } else if (scrollState.current.type === "OffsetTop") { - animateScroll.scrollTo( - Math.max( - 101, - ref.current.scrollTop + - (ref.current.scrollHeight - scrollState.current.previousHeight) - ), - { - container: ref.current, - duration: 0 - } - ); - - setScrollState({ type: "Free" }); - } else if (scrollState.current.type === "ScrollTop") { - animateScroll.scrollTo(scrollState.current.y, { - container: ref.current, - duration: 0 - }); - - setScrollState({ type: "Free" }); - } - }); - } - - // ? Determine if we are at the bottom of the scroll container. - // -> https://stackoverflow.com/a/44893438 - // By default, we assume we are at the bottom, i.e. when we first load. - const atBottom = (offset = 0) => - ref.current - ? Math.floor(ref.current.scrollHeight - ref.current.scrollTop) - - offset <= - ref.current.clientHeight - : true; - - const atTop = (offset = 0) => ref.current.scrollTop <= offset; - - // ? Handle events from renderer. - useEffect(() => { - SingletonMessageRenderer.addListener('state', setState); - return () => SingletonMessageRenderer.removeListener('state', setState); - }, [ ]); - - useEffect(() => { - SingletonMessageRenderer.addListener('scroll', setScrollState); - return () => SingletonMessageRenderer.removeListener('scroll', setScrollState); - }, [ scrollState ]); - - // ? Load channel initially. - useEffect(() => { - SingletonMessageRenderer.init(id); - }, [ id ]); - - // ? If we are waiting for network, try again. - useEffect(() => { - switch (status) { - case ClientStatus.ONLINE: - if (state.type === 'WAITING_FOR_NETWORK') { - SingletonMessageRenderer.init(id); - } else { - SingletonMessageRenderer.reloadStale(id); - } - - break; - case ClientStatus.OFFLINE: - case ClientStatus.DISCONNECTED: - case ClientStatus.CONNECTING: - SingletonMessageRenderer.markStale(); - break; - } - }, [ status, state ]); - - // ? When the container is scrolled. - // ? Also handle StayAtBottom - useEffect(() => { - async function onScroll() { - if (scrollState.current.type === "Free" && atBottom()) { - setScrollState({ type: "Bottom" }); - } else if (scrollState.current.type === "Bottom" && !atBottom()) { - if (scrollState.current.scrollingUntil && scrollState.current.scrollingUntil > + new Date()) return; - setScrollState({ type: "Free" }); - } - } - - ref.current.addEventListener("scroll", onScroll); - return () => ref.current.removeEventListener("scroll", onScroll); - }, [ref, scrollState]); - - // ? Top and bottom loaders. - useEffect(() => { - async function onScroll() { - if (atTop(100)) { - SingletonMessageRenderer.loadTop(ref.current); - } - - if (atBottom(100)) { - SingletonMessageRenderer.loadBottom(ref.current); - } - } - - ref.current.addEventListener("scroll", onScroll); - return () => ref.current.removeEventListener("scroll", onScroll); - }, [ref]); - - // ? Scroll down whenever the message area resizes. - function stbOnResize() { - if (!atBottom() && scrollState.current.type === "Bottom") { - animateScroll.scrollToBottom({ - container: ref.current, - duration: 0 - }); - - setScrollState({ type: "Bottom" }); - } - } - - // ? Scroll down when container resized. - useLayoutEffect(() => { - stbOnResize(); - }, [height]); - - // ? Scroll down whenever the window resizes. - useLayoutEffect(() => { - document.addEventListener("resize", stbOnResize); - return () => document.removeEventListener("resize", stbOnResize); - }, [ref, scrollState]); - - // ? Scroll to bottom when pressing 'Escape'. - useEffect(() => { - function keyUp(e: KeyboardEvent) { - if (e.key === "Escape" && !focusTaken) { - SingletonMessageRenderer.jumpToBottom(id, true); - internalEmit("TextArea", "focus", "message"); - } - } - - document.body.addEventListener("keyup", keyUp); - return () => document.body.removeEventListener("keyup", keyUp); - }, [ref, focusTaken]); - - return ( - <MessageAreaWidthContext.Provider value={(width ?? 0) - MESSAGE_AREA_PADDING}> - <Area ref={ref}> - <div> - {state.type === "LOADING" && <Preloader type="ring" />} - {state.type === "WAITING_FOR_NETWORK" && ( - <RequiresOnline> - <Preloader type="ring" /> - </RequiresOnline> - )} - {state.type === "RENDER" && ( - <MessageRenderer id={id} state={state} /> - )} - {state.type === "EMPTY" && <ConversationStart id={id} />} - </div> - </Area> - </MessageAreaWidthContext.Provider> - ); + const status = useContext(StatusContext); + const { focusTaken } = useContext(IntermediateContext); + + // ? This is the scroll container. + const ref = useRef<HTMLDivElement>(null); + const { width, height } = useResizeObserver<HTMLDivElement>({ ref }); + + // ? Current channel state. + const [state, setState] = useState<RenderState>({ type: "LOADING" }); + + // ? useRef to avoid re-renders + const scrollState = useRef<ScrollState>({ type: "Free" }); + + const setScrollState = (v: ScrollState) => { + if (v.type === "StayAtBottom") { + if (scrollState.current.type === "Bottom" || atBottom()) { + scrollState.current = { + type: "ScrollToBottom", + smooth: v.smooth, + }; + } else { + scrollState.current = { type: "Free" }; + } + } else { + scrollState.current = v; + } + + defer(() => { + if (scrollState.current.type === "ScrollToBottom") { + setScrollState({ + type: "Bottom", + scrollingUntil: +new Date() + 150, + }); + + animateScroll.scrollToBottom({ + container: ref.current, + duration: scrollState.current.smooth ? 150 : 0, + }); + } else if (scrollState.current.type === "OffsetTop") { + animateScroll.scrollTo( + Math.max( + 101, + ref.current.scrollTop + + (ref.current.scrollHeight - + scrollState.current.previousHeight), + ), + { + container: ref.current, + duration: 0, + }, + ); + + setScrollState({ type: "Free" }); + } else if (scrollState.current.type === "ScrollTop") { + animateScroll.scrollTo(scrollState.current.y, { + container: ref.current, + duration: 0, + }); + + setScrollState({ type: "Free" }); + } + }); + }; + + // ? Determine if we are at the bottom of the scroll container. + // -> https://stackoverflow.com/a/44893438 + // By default, we assume we are at the bottom, i.e. when we first load. + const atBottom = (offset = 0) => + ref.current + ? Math.floor(ref.current.scrollHeight - ref.current.scrollTop) - + offset <= + ref.current.clientHeight + : true; + + const atTop = (offset = 0) => ref.current.scrollTop <= offset; + + // ? Handle events from renderer. + useEffect(() => { + SingletonMessageRenderer.addListener("state", setState); + return () => SingletonMessageRenderer.removeListener("state", setState); + }, []); + + useEffect(() => { + SingletonMessageRenderer.addListener("scroll", setScrollState); + return () => + SingletonMessageRenderer.removeListener("scroll", setScrollState); + }, [scrollState]); + + // ? Load channel initially. + useEffect(() => { + SingletonMessageRenderer.init(id); + }, [id]); + + // ? If we are waiting for network, try again. + useEffect(() => { + switch (status) { + case ClientStatus.ONLINE: + if (state.type === "WAITING_FOR_NETWORK") { + SingletonMessageRenderer.init(id); + } else { + SingletonMessageRenderer.reloadStale(id); + } + + break; + case ClientStatus.OFFLINE: + case ClientStatus.DISCONNECTED: + case ClientStatus.CONNECTING: + SingletonMessageRenderer.markStale(); + break; + } + }, [status, state]); + + // ? When the container is scrolled. + // ? Also handle StayAtBottom + useEffect(() => { + async function onScroll() { + if (scrollState.current.type === "Free" && atBottom()) { + setScrollState({ type: "Bottom" }); + } else if (scrollState.current.type === "Bottom" && !atBottom()) { + if ( + scrollState.current.scrollingUntil && + scrollState.current.scrollingUntil > +new Date() + ) + return; + setScrollState({ type: "Free" }); + } + } + + ref.current.addEventListener("scroll", onScroll); + return () => ref.current.removeEventListener("scroll", onScroll); + }, [ref, scrollState]); + + // ? Top and bottom loaders. + useEffect(() => { + async function onScroll() { + if (atTop(100)) { + SingletonMessageRenderer.loadTop(ref.current); + } + + if (atBottom(100)) { + SingletonMessageRenderer.loadBottom(ref.current); + } + } + + ref.current.addEventListener("scroll", onScroll); + return () => ref.current.removeEventListener("scroll", onScroll); + }, [ref]); + + // ? Scroll down whenever the message area resizes. + function stbOnResize() { + if (!atBottom() && scrollState.current.type === "Bottom") { + animateScroll.scrollToBottom({ + container: ref.current, + duration: 0, + }); + + setScrollState({ type: "Bottom" }); + } + } + + // ? Scroll down when container resized. + useLayoutEffect(() => { + stbOnResize(); + }, [height]); + + // ? Scroll down whenever the window resizes. + useLayoutEffect(() => { + document.addEventListener("resize", stbOnResize); + return () => document.removeEventListener("resize", stbOnResize); + }, [ref, scrollState]); + + // ? Scroll to bottom when pressing 'Escape'. + useEffect(() => { + function keyUp(e: KeyboardEvent) { + if (e.key === "Escape" && !focusTaken) { + SingletonMessageRenderer.jumpToBottom(id, true); + internalEmit("TextArea", "focus", "message"); + } + } + + document.body.addEventListener("keyup", keyUp); + return () => document.body.removeEventListener("keyup", keyUp); + }, [ref, focusTaken]); + + return ( + <MessageAreaWidthContext.Provider + value={(width ?? 0) - MESSAGE_AREA_PADDING}> + <Area ref={ref}> + <div> + {state.type === "LOADING" && <Preloader type="ring" />} + {state.type === "WAITING_FOR_NETWORK" && ( + <RequiresOnline> + <Preloader type="ring" /> + </RequiresOnline> + )} + {state.type === "RENDER" && ( + <MessageRenderer id={id} state={state} /> + )} + {state.type === "EMPTY" && <ConversationStart id={id} />} + </div> + </Area> + </MessageAreaWidthContext.Provider> + ); } diff --git a/src/pages/channels/messaging/MessageEditor.tsx b/src/pages/channels/messaging/MessageEditor.tsx index 7e8ecd17195f28cbc79825a1bdff7dd8c2c82fda..78fe70bb4ef7752d6dca89983d671042207076cf 100644 --- a/src/pages/channels/messaging/MessageEditor.tsx +++ b/src/pages/channels/messaging/MessageEditor.tsx @@ -1,115 +1,133 @@ import styled from "styled-components"; -import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; -import { MessageObject } from "../../../context/revoltjs/util"; + import { useContext, useEffect, useState } from "preact/hooks"; -import { AppContext } from "../../../context/revoltjs/RevoltClient"; + +import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; -import { IntermediateContext, useIntermediate } from "../../../context/intermediate/Intermediate"; -import AutoComplete, { useAutoComplete } from "../../../components/common/AutoComplete"; + +import { + IntermediateContext, + useIntermediate, +} from "../../../context/intermediate/Intermediate"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { MessageObject } from "../../../context/revoltjs/util"; + +import AutoComplete, { + useAutoComplete, +} from "../../../components/common/AutoComplete"; const EditorBase = styled.div` - display: flex; - flex-direction: column; - - textarea { - resize: none; - padding: 12px; - font-size: .875rem; - border-radius: 3px; - white-space: pre-wrap; - background: var(--secondary-header); - } - - .caption { - padding: 2px; - font-size: 11px; - color: var(--tertiary-foreground); - - a { - cursor: pointer; - &:hover { - text-decoration: underline; - } - } - } + display: flex; + flex-direction: column; + + textarea { + resize: none; + padding: 12px; + font-size: 0.875rem; + border-radius: 3px; + white-space: pre-wrap; + background: var(--secondary-header); + } + + .caption { + padding: 2px; + font-size: 11px; + color: var(--tertiary-foreground); + + a { + cursor: pointer; + &:hover { + text-decoration: underline; + } + } + } `; interface Props { - message: MessageObject - finish: () => void + message: MessageObject; + finish: () => void; } export default function MessageEditor({ message, finish }: Props) { - const [ content, setContent ] = useState(message.content as string ?? ''); - const { focusTaken } = useContext(IntermediateContext); - const { openScreen } = useIntermediate(); - const client = useContext(AppContext); - - async function save() { - finish(); - - if (content.length === 0) { - // @ts-expect-error - openScreen({ id: 'special_prompt', type: 'delete_message', target: message }); - } else if (content !== message.content) { - await client.channels.editMessage( - message.channel, - message._id, - { content } - ); - } - } - - // ? Stop editing when pressing ESC. - useEffect(() => { - function keyUp(e: KeyboardEvent) { - if (e.key === "Escape" && !focusTaken) { - finish(); - } - } - - document.body.addEventListener("keyup", keyUp); - return () => document.body.removeEventListener("keyup", keyUp); - }, [focusTaken]); - - const { onChange, onKeyUp, onKeyDown, onFocus, onBlur, ...autoCompleteProps } = - useAutoComplete(v => setContent(v ?? ''), { - users: { type: 'all' } - }); - - return ( - <EditorBase> - <AutoComplete detached {...autoCompleteProps} /> - <TextAreaAutoSize - forceFocus - maxRows={3} - padding={12} - value={content} - maxLength={2000} - onChange={ev => { - onChange(ev); - setContent(ev.currentTarget.value) - }} - onKeyDown={e => { - if (onKeyDown(e)) return; - - if ( - !e.shiftKey && - e.key === "Enter" && - !isTouchscreenDevice - ) { - e.preventDefault(); - save(); - } - }} - onKeyUp={onKeyUp} - onFocus={onFocus} - onBlur={onBlur} - /> - <span className="caption"> - escape to <a onClick={finish}>cancel</a> · - enter to <a onClick={save}>save</a> - </span> - </EditorBase> - ) + const [content, setContent] = useState((message.content as string) ?? ""); + const { focusTaken } = useContext(IntermediateContext); + const { openScreen } = useIntermediate(); + const client = useContext(AppContext); + + async function save() { + finish(); + + if (content.length === 0) { + openScreen({ + id: "special_prompt", + // @ts-expect-error + type: "delete_message", + // @ts-expect-error + target: message, + }); + } else if (content !== message.content) { + await client.channels.editMessage(message.channel, message._id, { + content, + }); + } + } + + // ? Stop editing when pressing ESC. + useEffect(() => { + function keyUp(e: KeyboardEvent) { + if (e.key === "Escape" && !focusTaken) { + finish(); + } + } + + document.body.addEventListener("keyup", keyUp); + return () => document.body.removeEventListener("keyup", keyUp); + }, [focusTaken]); + + const { + onChange, + onKeyUp, + onKeyDown, + onFocus, + onBlur, + ...autoCompleteProps + } = useAutoComplete((v) => setContent(v ?? ""), { + users: { type: "all" }, + }); + + return ( + <EditorBase> + <AutoComplete detached {...autoCompleteProps} /> + <TextAreaAutoSize + forceFocus + maxRows={3} + padding={12} + value={content} + maxLength={2000} + onChange={(ev) => { + onChange(ev); + setContent(ev.currentTarget.value); + }} + onKeyDown={(e) => { + if (onKeyDown(e)) return; + + if ( + !e.shiftKey && + e.key === "Enter" && + !isTouchscreenDevice + ) { + e.preventDefault(); + save(); + } + }} + onKeyUp={onKeyUp} + onFocus={onFocus} + onBlur={onBlur} + /> + <span className="caption"> + escape to <a onClick={finish}>cancel</a> · enter to{" "} + <a onClick={save}>save</a> + </span> + </EditorBase> + ); } diff --git a/src/pages/channels/messaging/MessageRenderer.tsx b/src/pages/channels/messaging/MessageRenderer.tsx index 0f01f2e2b0354819d252db40b0d0f0022f4bb217..1a9ae45a21cc52e3a4b4fc51aede9a8dee840e02 100644 --- a/src/pages/channels/messaging/MessageRenderer.tsx +++ b/src/pages/channels/messaging/MessageRenderer.tsx @@ -1,198 +1,215 @@ +import { X } from "@styled-icons/boxicons-regular"; +import { Users } from "revolt.js/dist/api/objects"; +import styled from "styled-components"; import { decodeTime } from "ulid"; + import { memo } from "preact/compat"; -import styled from "styled-components"; -import MessageEditor from "./MessageEditor"; -import { Children } from "../../../types/Preact"; -import { Users } from "revolt.js/dist/api/objects"; -import { X } from "@styled-icons/boxicons-regular"; -import ConversationStart from "./ConversationStart"; -import { connectState } from "../../../redux/connector"; -import Preloader from "../../../components/ui/Preloader"; +import { useContext, useEffect, useState } from "preact/hooks"; + +import { internalSubscribe, internalEmit } from "../../../lib/eventEmitter"; import { RenderState } from "../../../lib/renderer/types"; -import DateDivider from "../../../components/ui/DateDivider"; + +import { connectState } from "../../../redux/connector"; import { QueuedMessage } from "../../../redux/reducers/queue"; -import { useContext, useEffect, useState } from "preact/hooks"; + +import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { MessageObject } from "../../../context/revoltjs/util"; + import Message from "../../../components/common/messaging/Message"; -import { AppContext } from "../../../context/revoltjs/RevoltClient"; -import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; -import { internalSubscribe, internalEmit } from "../../../lib/eventEmitter"; import { SystemMessage } from "../../../components/common/messaging/SystemMessage"; +import DateDivider from "../../../components/ui/DateDivider"; +import Preloader from "../../../components/ui/Preloader"; + +import { Children } from "../../../types/Preact"; +import ConversationStart from "./ConversationStart"; +import MessageEditor from "./MessageEditor"; interface Props { - id: string; - state: RenderState; - queue: QueuedMessage[]; + id: string; + state: RenderState; + queue: QueuedMessage[]; } const BlockedMessage = styled.div` - font-size: 0.8em; - margin-top: 6px; - padding: 4px 64px; - color: var(--tertiary-foreground); - - &:hover { - background: var(--hover); - } + font-size: 0.8em; + margin-top: 6px; + padding: 4px 64px; + color: var(--tertiary-foreground); + + &:hover { + background: var(--hover); + } `; function MessageRenderer({ id, state, queue }: Props) { - if (state.type !== 'RENDER') return null; - - const client = useContext(AppContext); - const userId = client.user!._id; - - const [editing, setEditing] = useState<string | undefined>(undefined); - const stopEditing = () => { - setEditing(undefined); - internalEmit("TextArea", "focus", "message"); - }; - - useEffect(() => { - function editLast() { - if (state.type !== 'RENDER') return; - for (let i = state.messages.length - 1; i >= 0; i--) { - if (state.messages[i].author === userId) { - setEditing(state.messages[i]._id); - return; - } - } - } - - const subs = [ - internalSubscribe("MessageRenderer", "edit_last", editLast), - internalSubscribe("MessageRenderer", "edit_message", setEditing) - ] - - return () => subs.forEach(unsub => unsub()); - }, [state.messages]); - - let render: Children[] = [], - previous: MessageObject | undefined; - - if (state.atTop) { - render.push(<ConversationStart id={id} />); - } else { - render.push( - <RequiresOnline> - <Preloader type="ring" /> - </RequiresOnline> - ); - } - - let head = true; - function compare( - current: string, - curAuthor: string, - previous: string, - prevAuthor: string - ) { - const atime = decodeTime(current), - adate = new Date(atime), - btime = decodeTime(previous), - bdate = new Date(btime); - - if ( - adate.getFullYear() !== bdate.getFullYear() || - adate.getMonth() !== bdate.getMonth() || - adate.getDate() !== bdate.getDate() - ) { - render.push(<DateDivider date={adate} />); - head = true; - } - - head = curAuthor !== prevAuthor || Math.abs(btime - atime) >= 420000; - } - - let blocked = 0; - function pushBlocked() { - render.push(<BlockedMessage><X size={16} /> { blocked } blocked messages</BlockedMessage>); - blocked = 0; - } - - for (const message of state.messages) { - if (previous) { - compare( - message._id, - message.author, - previous._id, - previous.author - ); - } - - if (message.author === "00000000000000000000000000") { - render.push(<SystemMessage key={message._id} message={message} attachContext />); - } else { - // ! FIXME: temp solution - if (client.users.get(message.author)?.relationship === Users.Relationship.Blocked) { - blocked++; - } else { - if (blocked > 0) pushBlocked(); - - render.push( - <Message message={message} - key={message._id} - head={head} - content={ - editing === message._id ? - <MessageEditor message={message} finish={stopEditing} /> - : undefined - } - attachContext /> - ); - } - } - - previous = message; - } - - if (blocked > 0) pushBlocked(); - - const nonces = state.messages.map(x => x.nonce); - if (state.atBottom) { - for (const msg of queue) { - if (msg.channel !== id) continue; - if (nonces.includes(msg.id)) continue; - - if (previous) { - compare( - msg.id, - userId!, - previous._id, - previous.author - ); - - previous = { - _id: msg.id, - data: { author: userId! } - } as any; - } - - render.push( - <Message - message={{ - ...msg.data, - replies: msg.data.replies.map(x => x.id) - }} - key={msg.id} - queued={msg} - head={head} - attachContext /> - ); - } - } else { - render.push( - <RequiresOnline> - <Preloader type="ring" /> - </RequiresOnline> - ); - } - - return <>{ render }</>; + if (state.type !== "RENDER") return null; + + const client = useContext(AppContext); + const userId = client.user!._id; + + const [editing, setEditing] = useState<string | undefined>(undefined); + const stopEditing = () => { + setEditing(undefined); + internalEmit("TextArea", "focus", "message"); + }; + + useEffect(() => { + function editLast() { + if (state.type !== "RENDER") return; + for (let i = state.messages.length - 1; i >= 0; i--) { + if (state.messages[i].author === userId) { + setEditing(state.messages[i]._id); + return; + } + } + } + + const subs = [ + internalSubscribe("MessageRenderer", "edit_last", editLast), + internalSubscribe("MessageRenderer", "edit_message", setEditing), + ]; + + return () => subs.forEach((unsub) => unsub()); + }, [state.messages]); + + let render: Children[] = [], + previous: MessageObject | undefined; + + if (state.atTop) { + render.push(<ConversationStart id={id} />); + } else { + render.push( + <RequiresOnline> + <Preloader type="ring" /> + </RequiresOnline>, + ); + } + + let head = true; + function compare( + current: string, + curAuthor: string, + previous: string, + prevAuthor: string, + ) { + const atime = decodeTime(current), + adate = new Date(atime), + btime = decodeTime(previous), + bdate = new Date(btime); + + if ( + adate.getFullYear() !== bdate.getFullYear() || + adate.getMonth() !== bdate.getMonth() || + adate.getDate() !== bdate.getDate() + ) { + render.push(<DateDivider date={adate} />); + head = true; + } + + head = curAuthor !== prevAuthor || Math.abs(btime - atime) >= 420000; + } + + let blocked = 0; + function pushBlocked() { + render.push( + <BlockedMessage> + <X size={16} /> {blocked} blocked messages + </BlockedMessage>, + ); + blocked = 0; + } + + for (const message of state.messages) { + if (previous) { + compare(message._id, message.author, previous._id, previous.author); + } + + if (message.author === "00000000000000000000000000") { + render.push( + <SystemMessage + key={message._id} + message={message} + attachContext + />, + ); + } else { + // ! FIXME: temp solution + if ( + client.users.get(message.author)?.relationship === + Users.Relationship.Blocked + ) { + blocked++; + } else { + if (blocked > 0) pushBlocked(); + + render.push( + <Message + message={message} + key={message._id} + head={head} + content={ + editing === message._id ? ( + <MessageEditor + message={message} + finish={stopEditing} + /> + ) : undefined + } + attachContext + />, + ); + } + } + + previous = message; + } + + if (blocked > 0) pushBlocked(); + + const nonces = state.messages.map((x) => x.nonce); + if (state.atBottom) { + for (const msg of queue) { + if (msg.channel !== id) continue; + if (nonces.includes(msg.id)) continue; + + if (previous) { + compare(msg.id, userId!, previous._id, previous.author); + + previous = { + _id: msg.id, + data: { author: userId! }, + } as any; + } + + render.push( + <Message + message={{ + ...msg.data, + replies: msg.data.replies.map((x) => x.id), + }} + key={msg.id} + queued={msg} + head={head} + attachContext + />, + ); + } + } else { + render.push( + <RequiresOnline> + <Preloader type="ring" /> + </RequiresOnline>, + ); + } + + return <>{render}</>; } -export default memo(connectState<Omit<Props, 'queue'>>(MessageRenderer, state => { - return { - queue: state.queue - }; -})); +export default memo( + connectState<Omit<Props, "queue">>(MessageRenderer, (state) => { + return { + queue: state.queue, + }; + }), +); diff --git a/src/pages/channels/voice/VoiceHeader.tsx b/src/pages/channels/voice/VoiceHeader.tsx index 16894650e1841fb363ff675e542b014bb2b2ec4d..106e27d8b1b5bae8f28882a4b3d638e96d120cdd 100644 --- a/src/pages/channels/voice/VoiceHeader.tsx +++ b/src/pages/channels/voice/VoiceHeader.tsx @@ -1,117 +1,138 @@ -import { Text } from "preact-i18n"; +import { BarChart } from "@styled-icons/boxicons-regular"; import styled from "styled-components"; + +import { Text } from "preact-i18n"; import { useContext } from "preact/hooks"; -import { BarChart } from "@styled-icons/boxicons-regular"; -import Button from "../../../components/ui/Button"; + +import { + VoiceContext, + VoiceOperationsContext, + VoiceStatus, +} from "../../../context/Voice"; +import { + useForceUpdate, + useSelf, + useUsers, +} from "../../../context/revoltjs/hooks"; + import UserIcon from "../../../components/common/user/UserIcon"; -import { useForceUpdate, useSelf, useUsers } from "../../../context/revoltjs/hooks"; -import { VoiceContext, VoiceOperationsContext, VoiceStatus } from "../../../context/Voice"; +import Button from "../../../components/ui/Button"; interface Props { - id: string + id: string; } const VoiceBase = styled.div` - padding: 20px; - background: var(--secondary-background); - - .status { - position: absolute; - color: var(--success); - background: var(--primary-background); - display: flex; - align-items: center; - padding: 10px; - font-size: 14px; - font-weight: 600; - border-radius: 7px; - flex: 1 0; - user-select: none; - - svg { - margin-inline-end: 4px; - cursor: help; - } - } - - display: flex; - flex-direction: column; - - .participants { - margin: 20px 0; - justify-content: center; - pointer-events: none; - user-select: none; - display: flex; - gap: 16px; - - .disconnected { - opacity: 0.5; - } - } - - .actions { - display: flex; - justify-content: center; - gap: 10px; - } + padding: 20px; + background: var(--secondary-background); + + .status { + position: absolute; + color: var(--success); + background: var(--primary-background); + display: flex; + align-items: center; + padding: 10px; + font-size: 14px; + font-weight: 600; + border-radius: 7px; + flex: 1 0; + user-select: none; + + svg { + margin-inline-end: 4px; + cursor: help; + } + } + + display: flex; + flex-direction: column; + + .participants { + margin: 20px 0; + justify-content: center; + pointer-events: none; + user-select: none; + display: flex; + gap: 16px; + + .disconnected { + opacity: 0.5; + } + } + + .actions { + display: flex; + justify-content: center; + gap: 10px; + } `; export default function VoiceHeader({ id }: Props) { - const { status, participants, roomId } = useContext(VoiceContext); - if (roomId !== id) return null; - - const { isProducing, startProducing, stopProducing, disconnect } = useContext(VoiceOperationsContext); - - const ctx = useForceUpdate(); - const self = useSelf(ctx); - const keys = participants ? Array.from(participants.keys()) : undefined; - const users = keys ? useUsers(keys, ctx) : undefined; - - return ( - <VoiceBase> - <div className="participants"> - { users && users.length !== 0 ? users.map((user, index) => { - const id = keys![index]; - return ( - <div key={id}> - <UserIcon - size={80} - target={user} - status={false} - voice={ participants!.get(id)?.audio ? undefined : "muted" } - /> - </div> - ); - }) : self !== undefined && ( - <div key={self._id} className="disconnected"> - <UserIcon - size={80} - target={self} - status={false} /> - </div> - )} - </div> - <div className="status"> - <BarChart size={20} /> - { status === VoiceStatus.CONNECTED && <Text id="app.main.channel.voice.connected" /> } - </div> - <div className="actions"> - <Button error onClick={disconnect}> - <Text id="app.main.channel.voice.leave" /> - </Button> - { isProducing("audio") ? ( - <Button onClick={() => stopProducing("audio")}> - <Text id="app.main.channel.voice.mute" /> - </Button> - ) : ( - <Button onClick={() => startProducing("audio")}> - <Text id="app.main.channel.voice.unmute" /> - </Button> - )} - </div> - </VoiceBase> - ) + const { status, participants, roomId } = useContext(VoiceContext); + if (roomId !== id) return null; + + const { isProducing, startProducing, stopProducing, disconnect } = + useContext(VoiceOperationsContext); + + const ctx = useForceUpdate(); + const self = useSelf(ctx); + const keys = participants ? Array.from(participants.keys()) : undefined; + const users = keys ? useUsers(keys, ctx) : undefined; + + return ( + <VoiceBase> + <div className="participants"> + {users && users.length !== 0 + ? users.map((user, index) => { + const id = keys![index]; + return ( + <div key={id}> + <UserIcon + size={80} + target={user} + status={false} + voice={ + participants!.get(id)?.audio + ? undefined + : "muted" + } + /> + </div> + ); + }) + : self !== undefined && ( + <div key={self._id} className="disconnected"> + <UserIcon + size={80} + target={self} + status={false} + /> + </div> + )} + </div> + <div className="status"> + <BarChart size={20} /> + {status === VoiceStatus.CONNECTED && ( + <Text id="app.main.channel.voice.connected" /> + )} + </div> + <div className="actions"> + <Button error onClick={disconnect}> + <Text id="app.main.channel.voice.leave" /> + </Button> + {isProducing("audio") ? ( + <Button onClick={() => stopProducing("audio")}> + <Text id="app.main.channel.voice.mute" /> + </Button> + ) : ( + <Button onClick={() => startProducing("audio")}> + <Text id="app.main.channel.voice.unmute" /> + </Button> + )} + </div> + </VoiceBase> + ); } /**{voice.roomId === id && ( diff --git a/src/pages/developer/Developer.tsx b/src/pages/developer/Developer.tsx index e8c3e187da029c0aeff7ea6dfad48cf1d5cccc8c..6e39a16ad6be51d4d466d1cc9e12879fde754312 100644 --- a/src/pages/developer/Developer.tsx +++ b/src/pages/developer/Developer.tsx @@ -1,34 +1,41 @@ +import { Wrench } from "@styled-icons/boxicons-solid"; + import { useContext } from "preact/hooks"; -import { TextReact } from "../../lib/i18n"; -import Header from "../../components/ui/Header"; + import PaintCounter from "../../lib/PaintCounter"; +import { TextReact } from "../../lib/i18n"; + import { AppContext } from "../../context/revoltjs/RevoltClient"; import { useUserPermission } from "../../context/revoltjs/hooks"; -import { Wrench } from "@styled-icons/boxicons-solid"; + +import Header from "../../components/ui/Header"; export default function Developer() { - // const voice = useContext(VoiceContext); - const client = useContext(AppContext); - const userPermission = useUserPermission(client.user!._id); + // const voice = useContext(VoiceContext); + const client = useContext(AppContext); + const userPermission = useUserPermission(client.user!._id); - return ( - <div> - <Header placement="primary"> - <Wrench size="24" /> - Developer Tab - </Header> - <div style={{ padding: "16px" }}> - <PaintCounter always /> - </div> - <div style={{ padding: "16px" }}> - <b>User ID:</b> {client.user!._id} <br/> - <b>Permission against self:</b> {userPermission} <br/> - </div> - <div style={{ padding: "16px" }}> - <TextReact id="login.open_mail_provider" fields={{ provider: <b>GAMING!</b> }} /> - </div> - <div style={{ padding: "16px" }}> - {/*<span> + return ( + <div> + <Header placement="primary"> + <Wrench size="24" /> + Developer Tab + </Header> + <div style={{ padding: "16px" }}> + <PaintCounter always /> + </div> + <div style={{ padding: "16px" }}> + <b>User ID:</b> {client.user!._id} <br /> + <b>Permission against self:</b> {userPermission} <br /> + </div> + <div style={{ padding: "16px" }}> + <TextReact + id="login.open_mail_provider" + fields={{ provider: <b>GAMING!</b> }} + /> + </div> + <div style={{ padding: "16px" }}> + {/*<span> <b>Voice Status:</b> {VoiceStatus[voice.status]} </span> <br /> @@ -41,7 +48,7 @@ export default function Developer() { {Array.from(voice.participants.keys()).join(", ")}] </span> <br />*/} - </div> - </div> - ); + </div> + </div> + ); } diff --git a/src/pages/friends/Friend.tsx b/src/pages/friends/Friend.tsx index d2a795e6f809c17d54904d9fdd89f959b168de9c..2632dcca64e0680917b0c3a79112b17ad11b24b8 100644 --- a/src/pages/friends/Friend.tsx +++ b/src/pages/friends/Friend.tsx @@ -1,107 +1,136 @@ -import { Text } from "preact-i18n"; -import classNames from "classnames"; -import styles from "./Friend.module.scss"; -import { useContext } from "preact/hooks"; -import { Children } from "../../types/Preact"; -import IconButton from "../../components/ui/IconButton"; -import { attachContextMenu } from "preact-context-menu"; import { X, Plus } from "@styled-icons/boxicons-regular"; +import { PhoneCall, Envelope } from "@styled-icons/boxicons-solid"; import { User, Users } from "revolt.js/dist/api/objects"; + +import styles from "./Friend.module.scss"; +import classNames from "classnames"; +import { attachContextMenu } from "preact-context-menu"; +import { Text } from "preact-i18n"; +import { useContext } from "preact/hooks"; + import { stopPropagation } from "../../lib/stopPropagation"; + import { VoiceOperationsContext } from "../../context/Voice"; -import UserIcon from "../../components/common/user/UserIcon"; -import UserStatus from '../../components/common/user/UserStatus'; -import { PhoneCall, Envelope } from "@styled-icons/boxicons-solid"; import { useIntermediate } from "../../context/intermediate/Intermediate"; -import { AppContext, OperationsContext } from "../../context/revoltjs/RevoltClient"; +import { + AppContext, + OperationsContext, +} from "../../context/revoltjs/RevoltClient"; + +import UserIcon from "../../components/common/user/UserIcon"; +import UserStatus from "../../components/common/user/UserStatus"; +import IconButton from "../../components/ui/IconButton"; + +import { Children } from "../../types/Preact"; interface Props { - user: User; + user: User; } export function Friend({ user }: Props) { - const client = useContext(AppContext); - const { openScreen } = useIntermediate(); - const { openDM } = useContext(OperationsContext); - const { connect } = useContext(VoiceOperationsContext); + const client = useContext(AppContext); + const { openScreen } = useIntermediate(); + const { openDM } = useContext(OperationsContext); + const { connect } = useContext(VoiceOperationsContext); - const actions: Children[] = []; - let subtext: Children = null; + const actions: Children[] = []; + let subtext: Children = null; - if (user.relationship === Users.Relationship.Friend) { - subtext = <UserStatus user={user} /> - actions.push( - <> - <IconButton type="circle" - className={classNames(styles.button, styles.call, styles.success)} - onClick={ev => stopPropagation(ev, openDM(user._id).then(connect))}> - <PhoneCall size={20} /> - </IconButton> - <IconButton type="circle" - className={styles.button} - onClick={ev => stopPropagation(ev, openDM(user._id))}> - <Envelope size={20} /> - </IconButton> - </> - ); - } + if (user.relationship === Users.Relationship.Friend) { + subtext = <UserStatus user={user} />; + actions.push( + <> + <IconButton + type="circle" + className={classNames( + styles.button, + styles.call, + styles.success, + )} + onClick={(ev) => + stopPropagation(ev, openDM(user._id).then(connect)) + }> + <PhoneCall size={20} /> + </IconButton> + <IconButton + type="circle" + className={styles.button} + onClick={(ev) => stopPropagation(ev, openDM(user._id))}> + <Envelope size={20} /> + </IconButton> + </>, + ); + } - if (user.relationship === Users.Relationship.Incoming) { - actions.push( - <IconButton type="circle" - className={styles.button} - onClick={ev => stopPropagation(ev, client.users.addFriend(user.username))}> - <Plus size={24} /> - </IconButton> - ); + if (user.relationship === Users.Relationship.Incoming) { + actions.push( + <IconButton + type="circle" + className={styles.button} + onClick={(ev) => + stopPropagation(ev, client.users.addFriend(user.username)) + }> + <Plus size={24} /> + </IconButton>, + ); - subtext = <Text id="app.special.friends.incoming" />; - } + subtext = <Text id="app.special.friends.incoming" />; + } - if (user.relationship === Users.Relationship.Outgoing) { - subtext = <Text id="app.special.friends.outgoing" />; - } + if (user.relationship === Users.Relationship.Outgoing) { + subtext = <Text id="app.special.friends.outgoing" />; + } - if ( - user.relationship === Users.Relationship.Friend || - user.relationship === Users.Relationship.Outgoing || - user.relationship === Users.Relationship.Incoming - ) { - actions.push( - <IconButton type="circle" - className={classNames(styles.button, styles.error)} - onClick={ev => stopPropagation(ev, - user.relationship === Users.Relationship.Friend ? - openScreen({ id: 'special_prompt', type: 'unfriend_user', target: user }) - : client.users.removeFriend(user._id) - )}> - <X size={24} /> - </IconButton> - ); - } + if ( + user.relationship === Users.Relationship.Friend || + user.relationship === Users.Relationship.Outgoing || + user.relationship === Users.Relationship.Incoming + ) { + actions.push( + <IconButton + type="circle" + className={classNames(styles.button, styles.error)} + onClick={(ev) => + stopPropagation( + ev, + user.relationship === Users.Relationship.Friend + ? openScreen({ + id: "special_prompt", + type: "unfriend_user", + target: user, + }) + : client.users.removeFriend(user._id), + ) + }> + <X size={24} /> + </IconButton>, + ); + } - if (user.relationship === Users.Relationship.Blocked) { - actions.push( - <IconButton type="circle" - className={classNames(styles.button, styles.error)} - onClick={ev => stopPropagation(ev, client.users.unblockUser(user._id))}> - <X size={24} /> - </IconButton> - ); - } + if (user.relationship === Users.Relationship.Blocked) { + actions.push( + <IconButton + type="circle" + className={classNames(styles.button, styles.error)} + onClick={(ev) => + stopPropagation(ev, client.users.unblockUser(user._id)) + }> + <X size={24} /> + </IconButton>, + ); + } - return ( - <div className={styles.friend} - onClick={() => openScreen({ id: 'profile', user_id: user._id })} - onContextMenu={attachContextMenu('Menu', { user: user._id })}> - <UserIcon target={user} size={36} status /> - <div className={styles.name}> - <span>@{user.username}</span> - {subtext && ( - <span className={styles.subtext}>{subtext}</span> - )} - </div> - <div className={styles.actions}>{actions}</div> - </div> - ); + return ( + <div + className={styles.friend} + onClick={() => openScreen({ id: "profile", user_id: user._id })} + onContextMenu={attachContextMenu("Menu", { user: user._id })}> + <UserIcon target={user} size={36} status /> + <div className={styles.name}> + <span>@{user.username}</span> + {subtext && <span className={styles.subtext}>{subtext}</span>} + </div> + <div className={styles.actions}>{actions}</div> + </div> + ); } diff --git a/src/pages/friends/Friends.tsx b/src/pages/friends/Friends.tsx index d90b5d58781dbe7b70a539bf8c4e1b79b5392991..6b322e5b108dcd3e8b395fa7aee5f9a90dd921d6 100644 --- a/src/pages/friends/Friends.tsx +++ b/src/pages/friends/Friends.tsx @@ -1,78 +1,117 @@ -import { Friend } from "./Friend"; -import { Text } from "preact-i18n"; -import styles from "./Friend.module.scss"; -import Header from "../../components/ui/Header"; -import Overline from "../../components/ui/Overline"; -import Tooltip from "../../components/common/Tooltip"; -import IconButton from "../../components/ui/IconButton"; -import { useUsers } from "../../context/revoltjs/hooks"; +import { + ChevronDown, + ChevronRight, + ListPlus, +} from "@styled-icons/boxicons-regular"; +import { UserDetail, MessageAdd, UserPlus } from "@styled-icons/boxicons-solid"; import { User, Users } from "revolt.js/dist/api/objects"; -import UserIcon from "../../components/common/user/UserIcon"; + +import styles from "./Friend.module.scss"; +import { Text } from "preact-i18n"; + +import { TextReact } from "../../lib/i18n"; import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; + import { useIntermediate } from "../../context/intermediate/Intermediate"; -import { ChevronDown, ChevronRight, ListPlus } from "@styled-icons/boxicons-regular"; -import { UserDetail, MessageAdd, UserPlus } from "@styled-icons/boxicons-solid"; -import { TextReact } from "../../lib/i18n"; -import { Children } from "../../types/Preact"; -import Details from "../../components/ui/Details"; +import { useUsers } from "../../context/revoltjs/hooks"; + import CollapsibleSection from "../../components/common/CollapsibleSection"; +import Tooltip from "../../components/common/Tooltip"; +import UserIcon from "../../components/common/user/UserIcon"; +import Details from "../../components/ui/Details"; +import Header from "../../components/ui/Header"; +import IconButton from "../../components/ui/IconButton"; +import Overline from "../../components/ui/Overline"; + +import { Children } from "../../types/Preact"; +import { Friend } from "./Friend"; export default function Friends() { - const { openScreen } = useIntermediate(); + const { openScreen } = useIntermediate(); - const users = useUsers() as User[]; - users.sort((a, b) => a.username.localeCompare(b.username)); + const users = useUsers() as User[]; + users.sort((a, b) => a.username.localeCompare(b.username)); - const friends = users.filter(x => x.relationship === Users.Relationship.Friend); + const friends = users.filter( + (x) => x.relationship === Users.Relationship.Friend, + ); - const lists = [ - [ '', users.filter(x => - x.relationship === Users.Relationship.Incoming - ) ], - [ 'app.special.friends.sent', users.filter(x => - x.relationship === Users.Relationship.Outgoing - ), 'outgoing' ], - [ 'app.status.online', friends.filter(x => - x.online && x.status?.presence !== Users.Presence.Invisible - ), 'online' ], - [ 'app.status.offline', friends.filter(x => - !x.online || x.status?.presence === Users.Presence.Invisible - ), 'offline' ], - [ 'app.special.friends.blocked', users.filter(x => - x.relationship === Users.Relationship.Blocked - ), 'blocked' ] - ] as [ string, User[], string ][]; + const lists = [ + [ + "", + users.filter((x) => x.relationship === Users.Relationship.Incoming), + ], + [ + "app.special.friends.sent", + users.filter((x) => x.relationship === Users.Relationship.Outgoing), + "outgoing", + ], + [ + "app.status.online", + friends.filter( + (x) => + x.online && x.status?.presence !== Users.Presence.Invisible, + ), + "online", + ], + [ + "app.status.offline", + friends.filter( + (x) => + !x.online || + x.status?.presence === Users.Presence.Invisible, + ), + "offline", + ], + [ + "app.special.friends.blocked", + users.filter((x) => x.relationship === Users.Relationship.Blocked), + "blocked", + ], + ] as [string, User[], string][]; - const incoming = lists[0][1]; - const userlist: Children[] = incoming.map(x => <b>{ x.username }</b>); - for (let i=incoming.length-1;i>0;i--) userlist.splice(i, 0, ', '); + const incoming = lists[0][1]; + const userlist: Children[] = incoming.map((x) => <b>{x.username}</b>); + for (let i = incoming.length - 1; i > 0; i--) userlist.splice(i, 0, ", "); - const isEmpty = lists.reduce((p: number, n) => p + n.length, 0) === 0; - return ( - <> - <Header placement="primary"> - { !isTouchscreenDevice && <UserDetail size={24} /> } - <div className={styles.title}> - <Text id="app.navigation.tabs.friends" /> - </div> - <div className={styles.actions}> - {/*<Tooltip content={"Create Category"} placement="bottom"> + const isEmpty = lists.reduce((p: number, n) => p + n.length, 0) === 0; + return ( + <> + <Header placement="primary"> + {!isTouchscreenDevice && <UserDetail size={24} />} + <div className={styles.title}> + <Text id="app.navigation.tabs.friends" /> + </div> + <div className={styles.actions}> + {/*<Tooltip content={"Create Category"} placement="bottom"> <IconButton onClick={() => openScreen({ id: 'special_input', type: 'create_group' })}> <ListPlus size={28} /> </IconButton> </Tooltip> <div className={styles.divider} />*/} - <Tooltip content={"Create Group"} placement="bottom"> - <IconButton onClick={() => openScreen({ id: 'special_input', type: 'create_group' })}> - <MessageAdd size={24} /> - </IconButton> - </Tooltip> - <Tooltip content={"Add Friend"} placement="bottom"> - <IconButton onClick={() => openScreen({ id: 'special_input', type: 'add_friend' })}> - <UserPlus size={27} /> - </IconButton> - </Tooltip> - {/* + <Tooltip content={"Create Group"} placement="bottom"> + <IconButton + onClick={() => + openScreen({ + id: "special_input", + type: "create_group", + }) + }> + <MessageAdd size={24} /> + </IconButton> + </Tooltip> + <Tooltip content={"Add Friend"} placement="bottom"> + <IconButton + onClick={() => + openScreen({ + id: "special_input", + type: "add_friend", + }) + }> + <UserPlus size={27} /> + </IconButton> + </Tooltip> + {/* <div className={styles.divider} /> <Tooltip content={"Friend Activity"} placement="bottom"> <IconButton> @@ -80,51 +119,98 @@ export default function Friends() { </IconButton> </Tooltip> */} - </div> - </Header> - <div className={styles.list} data-empty={isEmpty}> - {isEmpty && ( - <> - <img src="https://img.insrt.uk/xexu7/XOPoBUTI47.png/raw" /> - <Text id="app.special.friends.nobody" /> - </> - )} + </div> + </Header> + <div className={styles.list} data-empty={isEmpty}> + {isEmpty && ( + <> + <img src="https://img.insrt.uk/xexu7/XOPoBUTI47.png/raw" /> + <Text id="app.special.friends.nobody" /> + </> + )} - { incoming.length > 0 && <div className={styles.pending} - onClick={() => openScreen({ id: 'pending_requests', users: incoming.map(x => x._id) })}> - <div className={styles.avatars}> - { incoming.map((x, i) => i < 3 && <UserIcon target={x} size={64} mask={ i < Math.min(incoming.length - 1, 2) ? "url(#overlap)" : undefined } />) } - </div> - <div className={styles.details}> - <div><Text id="app.special.friends.pending" /> <span>{ incoming.length }</span></div> - <span> - { - incoming.length > 3 ? <TextReact id="app.special.friends.from.several" fields={{ userlist: userlist.slice(0, 6), count: incoming.length - 3 }} /> - : incoming.length > 1 ? <TextReact id="app.special.friends.from.multiple" fields={{ user: userlist.shift()!, userlist: userlist.slice(1) }} /> - : <TextReact id="app.special.friends.from.single" fields={{ user: userlist[0] }} /> - } - </span> - </div> - <ChevronRight size={28} /> - </div> } + {incoming.length > 0 && ( + <div + className={styles.pending} + onClick={() => + openScreen({ + id: "pending_requests", + users: incoming.map((x) => x._id), + }) + }> + <div className={styles.avatars}> + {incoming.map( + (x, i) => + i < 3 && ( + <UserIcon + target={x} + size={64} + mask={ + i < + Math.min(incoming.length - 1, 2) + ? "url(#overlap)" + : undefined + } + /> + ), + )} + </div> + <div className={styles.details}> + <div> + <Text id="app.special.friends.pending" />{" "} + <span>{incoming.length}</span> + </div> + <span> + {incoming.length > 3 ? ( + <TextReact + id="app.special.friends.from.several" + fields={{ + userlist: userlist.slice(0, 6), + count: incoming.length - 3, + }} + /> + ) : incoming.length > 1 ? ( + <TextReact + id="app.special.friends.from.multiple" + fields={{ + user: userlist.shift()!, + userlist: userlist.slice(1), + }} + /> + ) : ( + <TextReact + id="app.special.friends.from.single" + fields={{ user: userlist[0] }} + /> + )} + </span> + </div> + <ChevronRight size={28} /> + </div> + )} - { - lists.map(([i18n, list, section_id], index) => { - if (index === 0) return; - if (list.length === 0) return; + {lists.map(([i18n, list, section_id], index) => { + if (index === 0) return; + if (list.length === 0) return; - return ( - <CollapsibleSection - id={`friends_${section_id}`} - defaultValue={true} - sticky large - summary={<div class="title"><Text id={i18n} /> — { list.length }</div>}> - { list.map(x => <Friend key={x._id} user={x} />) } - </CollapsibleSection> - ) - }) - } - </div> - </> - ); + return ( + <CollapsibleSection + id={`friends_${section_id}`} + defaultValue={true} + sticky + large + summary={ + <div class="title"> + <Text id={i18n} /> — {list.length} + </div> + }> + {list.map((x) => ( + <Friend key={x._id} user={x} /> + ))} + </CollapsibleSection> + ); + })} + </div> + </> + ); } diff --git a/src/pages/home/Home.tsx b/src/pages/home/Home.tsx index d45e15b57c584c04f5c36350bbb03027c602ed08..50f3c4941fbfb2efd81a2b94531be8b65f4ee6e8 100644 --- a/src/pages/home/Home.tsx +++ b/src/pages/home/Home.tsx @@ -1,39 +1,41 @@ -import styles from "./Home.module.scss"; +import { Home as HomeIcon } from "@styled-icons/boxicons-solid"; import { Link } from "react-router-dom"; +import styles from "./Home.module.scss"; import { Text } from "preact-i18n"; + +import wideSVG from "../../assets/wide.svg"; import Header from "../../components/ui/Header"; -import { Home as HomeIcon } from "@styled-icons/boxicons-solid"; -import wideSVG from '../../assets/wide.svg'; export default function Home() { - return ( - <div className={styles.home}> - <Header placement="primary"> - <HomeIcon size={24} /> - <Text id="app.navigation.tabs.home" /> - </Header> - <h3> - <Text id="app.special.modals.onboarding.welcome" /> <img src={wideSVG} /> - </h3> - <ul> - <li> - Go to your <Link to="/friends">friends list</Link>. - </li> - <li> - Give <Link to="/settings/feedback">feedback</Link>. - </li> - <li> - Join <Link to="/invite/Testers">testers server</Link>. - </li> - <li> - View{" "} - <a href="https://gitlab.insrt.uk/revolt" target="_blank"> - source code - </a> - . - </li> - </ul> - </div> - ); + return ( + <div className={styles.home}> + <Header placement="primary"> + <HomeIcon size={24} /> + <Text id="app.navigation.tabs.home" /> + </Header> + <h3> + <Text id="app.special.modals.onboarding.welcome" />{" "} + <img src={wideSVG} /> + </h3> + <ul> + <li> + Go to your <Link to="/friends">friends list</Link>. + </li> + <li> + Give <Link to="/settings/feedback">feedback</Link>. + </li> + <li> + Join <Link to="/invite/Testers">testers server</Link>. + </li> + <li> + View{" "} + <a href="https://gitlab.insrt.uk/revolt" target="_blank"> + source code + </a> + . + </li> + </ul> + </div> + ); } diff --git a/src/pages/invite/Invite.tsx b/src/pages/invite/Invite.tsx index e05bcdf3f9f27a369b2e76c67dd02762c0858524..eae15f08290f38e71b401d6cc00ea91687f3a22e 100644 --- a/src/pages/invite/Invite.tsx +++ b/src/pages/invite/Invite.tsx @@ -1,88 +1,128 @@ -import styles from './Invite.module.scss'; -import Button from '../../components/ui/Button'; import { ArrowBack } from "@styled-icons/boxicons-regular"; -import Overline from '../../components/ui/Overline'; -import { Invites } from "revolt.js/dist/api/objects"; -import Preloader from '../../components/ui/Preloader'; -import { takeError } from "../../context/revoltjs/util"; import { useHistory, useParams } from "react-router-dom"; -import ServerIcon from '../../components/common/ServerIcon'; -import UserIcon from '../../components/common/user/UserIcon'; +import { Invites } from "revolt.js/dist/api/objects"; + +import styles from "./Invite.module.scss"; import { useContext, useEffect, useState } from "preact/hooks"; -import RequiresOnline from '../../context/revoltjs/RequiresOnline'; -import { AppContext, ClientStatus, StatusContext } from "../../context/revoltjs/RevoltClient"; + +import RequiresOnline from "../../context/revoltjs/RequiresOnline"; +import { + AppContext, + ClientStatus, + StatusContext, +} from "../../context/revoltjs/RevoltClient"; +import { takeError } from "../../context/revoltjs/util"; + +import ServerIcon from "../../components/common/ServerIcon"; +import UserIcon from "../../components/common/user/UserIcon"; +import Button from "../../components/ui/Button"; +import Overline from "../../components/ui/Overline"; +import Preloader from "../../components/ui/Preloader"; export default function Invite() { - const history = useHistory(); - const client = useContext(AppContext); - const status = useContext(StatusContext); - const { code } = useParams<{ code: string }>(); - const [ processing, setProcessing ] = useState(false); - const [ error, setError ] = useState<string | undefined>(undefined); - const [ invite, setInvite ] = useState<Invites.RetrievedInvite | undefined>(undefined); + const history = useHistory(); + const client = useContext(AppContext); + const status = useContext(StatusContext); + const { code } = useParams<{ code: string }>(); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState<string | undefined>(undefined); + const [invite, setInvite] = useState<Invites.RetrievedInvite | undefined>( + undefined, + ); - useEffect(() => { - if (typeof invite === 'undefined' && (status === ClientStatus.ONLINE || status === ClientStatus.READY)) { - client.fetchInvite(code) - .then(data => setInvite(data)) - .catch(err => setError(takeError(err))) - } - }, [ status ]); + useEffect(() => { + if ( + typeof invite === "undefined" && + (status === ClientStatus.ONLINE || status === ClientStatus.READY) + ) { + client + .fetchInvite(code) + .then((data) => setInvite(data)) + .catch((err) => setError(takeError(err))); + } + }, [status]); - if (typeof invite === 'undefined') { - return ( - <div className={styles.preloader}> - <RequiresOnline> - { error ? <Overline type="error" error={error} /> - : <Preloader type="spinner" /> } - </RequiresOnline> - </div> - ) - } + if (typeof invite === "undefined") { + return ( + <div className={styles.preloader}> + <RequiresOnline> + {error ? ( + <Overline type="error" error={error} /> + ) : ( + <Preloader type="spinner" /> + )} + </RequiresOnline> + </div> + ); + } - // ! FIXME: add i18n translations - return ( - <div className={styles.invite} style={{ backgroundImage: invite.server_banner ? `url('${client.generateFileURL(invite.server_banner)}')` : undefined }}> - <div className={styles.leave}> - <ArrowBack size={32} onClick={() => history.push('/')} /> - </div> + // ! FIXME: add i18n translations + return ( + <div + className={styles.invite} + style={{ + backgroundImage: invite.server_banner + ? `url('${client.generateFileURL(invite.server_banner)}')` + : undefined, + }}> + <div className={styles.leave}> + <ArrowBack size={32} onClick={() => history.push("/")} /> + </div> - { !processing && - <div className={styles.icon}> - <ServerIcon attachment={invite.server_icon} server_name={invite.server_name} size={64} /> - </div> } + {!processing && ( + <div className={styles.icon}> + <ServerIcon + attachment={invite.server_icon} + server_name={invite.server_name} + size={64} + /> + </div> + )} - <div className={styles.details}> - { processing ? <Preloader type="ring" /> : - <> - <h1>{ invite.server_name }</h1> - <h2>#{invite.channel_name}</h2> - <h3>Invited by <UserIcon size={24} attachment={invite.user_avatar} /> { invite.user_name }</h3> - <Overline type="error" error={error} /> - <Button contrast - onClick={ - async () => { - if (status === ClientStatus.READY) { - return history.push('/'); - } + <div className={styles.details}> + {processing ? ( + <Preloader type="ring" /> + ) : ( + <> + <h1>{invite.server_name}</h1> + <h2>#{invite.channel_name}</h2> + <h3> + Invited by{" "} + <UserIcon + size={24} + attachment={invite.user_avatar} + />{" "} + {invite.user_name} + </h3> + <Overline type="error" error={error} /> + <Button + contrast + onClick={async () => { + if (status === ClientStatus.READY) { + return history.push("/"); + } - try { - setProcessing(true); + try { + setProcessing(true); - let result = await client.joinInvite(code); - if (result.type === 'Server') { - history.push(`/server/${result.server._id}/channel/${result.channel._id}`); - } - } catch (err) { - setError(takeError(err)); - setProcessing(false); - } - } - } - >{ status === ClientStatus.READY ? 'Login to REVOLT' : 'Accept Invite' }</Button> - </> - } - </div> - </div> - ); + let result = await client.joinInvite(code); + if (result.type === "Server") { + history.push( + `/server/${result.server._id}/channel/${result.channel._id}`, + ); + } + } catch (err) { + setError(takeError(err)); + setProcessing(false); + } + }}> + {status === ClientStatus.READY + ? "Login to REVOLT" + : "Accept Invite"} + </Button> + </> + )} + </div> + </div> + ); } diff --git a/src/pages/login/FormField.tsx b/src/pages/login/FormField.tsx index 94a36098d7bda22f459b56d4d2bf3da804f1067b..059e6be8249c385d44435c7cf61ef9803fa8f818 100644 --- a/src/pages/login/FormField.tsx +++ b/src/pages/login/FormField.tsx @@ -1,70 +1,71 @@ -import Overline from '../../components/ui/Overline'; -import InputBox from '../../components/ui/InputBox'; -import { Text, Localizer } from 'preact-i18n'; +import { Text, Localizer } from "preact-i18n"; + +import InputBox from "../../components/ui/InputBox"; +import Overline from "../../components/ui/Overline"; interface Props { - type: "email" | "username" | "password" | "invite" | "current_password"; - showOverline?: boolean; - register: Function; - error?: string; - name?: string; + type: "email" | "username" | "password" | "invite" | "current_password"; + showOverline?: boolean; + register: Function; + error?: string; + name?: string; } export default function FormField({ - type, - register, - showOverline, - error, - name + type, + register, + showOverline, + error, + name, }: Props) { - return ( - <> - {showOverline && ( - <Overline error={error}> - <Text id={`login.${type}`} /> - </Overline> - )} - <Localizer> - <InputBox - // Styled uses React typing while we use Preact - // this leads to inconsistances where things need to be typed oddly - placeholder={(<Text id={`login.enter.${type}`} />) as any} - name={ - type === "current_password" ? "password" : name ?? type - } - type={ - type === "invite" || type === "username" - ? "text" - : type === "current_password" - ? "password" - : type - } - ref={register( - type === "password" || type === "current_password" - ? { - validate: (value: string) => - value.length === 0 - ? "RequiredField" - : value.length < 8 - ? "TooShort" - : value.length > 1024 - ? "TooLong" - : undefined - } - : type === "email" - ? { - required: "RequiredField", - pattern: { - value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, - message: "InvalidEmail" - } - } - : type === "username" - ? { required: "RequiredField" } - : { required: "RequiredField" } - )} - /> - </Localizer> - </> - ); + return ( + <> + {showOverline && ( + <Overline error={error}> + <Text id={`login.${type}`} /> + </Overline> + )} + <Localizer> + <InputBox + // Styled uses React typing while we use Preact + // this leads to inconsistances where things need to be typed oddly + placeholder={(<Text id={`login.enter.${type}`} />) as any} + name={ + type === "current_password" ? "password" : name ?? type + } + type={ + type === "invite" || type === "username" + ? "text" + : type === "current_password" + ? "password" + : type + } + ref={register( + type === "password" || type === "current_password" + ? { + validate: (value: string) => + value.length === 0 + ? "RequiredField" + : value.length < 8 + ? "TooShort" + : value.length > 1024 + ? "TooLong" + : undefined, + } + : type === "email" + ? { + required: "RequiredField", + pattern: { + value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, + message: "InvalidEmail", + }, + } + : type === "username" + ? { required: "RequiredField" } + : { required: "RequiredField" }, + )} + /> + </Localizer> + </> + ); } diff --git a/src/pages/login/Login.tsx b/src/pages/login/Login.tsx index e24f333e74367cecc0afec309789e704fea59ff9..ffc2e2cd5da5c1e59d7e15ebc7da84cae6d51694 100644 --- a/src/pages/login/Login.tsx +++ b/src/pages/login/Login.tsx @@ -1,72 +1,74 @@ -import { Text } from "preact-i18n"; import { Helmet } from "react-helmet"; +import { Route, Switch } from "react-router-dom"; +import { LIBRARY_VERSION } from "revolt.js"; + import styles from "./Login.module.scss"; +import { Text } from "preact-i18n"; import { useContext } from "preact/hooks"; -import { APP_VERSION } from "../../version"; -import { LIBRARY_VERSION } from "revolt.js"; -import { Route, Switch } from "react-router-dom"; + import { ThemeContext } from "../../context/Theme"; import { AppContext } from "../../context/revoltjs/RevoltClient"; + import LocaleSelector from "../../components/common/LocaleSelector"; +import { APP_VERSION } from "../../version"; import background from "./background.jpg"; - -import { FormLogin } from "./forms/FormLogin"; import { FormCreate } from "./forms/FormCreate"; +import { FormLogin } from "./forms/FormLogin"; import { FormResend } from "./forms/FormResend"; import { FormReset, FormSendReset } from "./forms/FormReset"; export default function Login() { - const theme = useContext(ThemeContext); - const client = useContext(AppContext); + const theme = useContext(ThemeContext); + const client = useContext(AppContext); - return ( - <div className={styles.login}> - <Helmet> - <meta name="theme-color" content={theme.background} /> - </Helmet> - <div className={styles.content}> - <div className={styles.attribution}> - <span> - API:{" "} - <code>{client.configuration?.revolt ?? "???"}</code>{" "} - · revolt.js: <code>{LIBRARY_VERSION}</code>{" "} - · App: <code>{APP_VERSION}</code> - </span> - <span> - <LocaleSelector /> - </span> - </div> - <div className={styles.modal}> - <Switch> - <Route path="/login/create"> - <FormCreate /> - </Route> - <Route path="/login/resend"> - <FormResend /> - </Route> - <Route path="/login/reset/:token"> - <FormReset /> - </Route> - <Route path="/login/reset"> - <FormSendReset /> - </Route> - <Route path="/"> - <FormLogin /> - </Route> - </Switch> - </div> - <div className={styles.attribution}> - <span> - <Text id="general.image_by" /> ‎@lorenzoherrera - ‏· unsplash.com - </span> - </div> - </div> - <div - className={styles.bg} - style={{ background: `url('${background}')` }} - /> - </div> - ); -}; + return ( + <div className={styles.login}> + <Helmet> + <meta name="theme-color" content={theme.background} /> + </Helmet> + <div className={styles.content}> + <div className={styles.attribution}> + <span> + API:{" "} + <code>{client.configuration?.revolt ?? "???"}</code>{" "} + · revolt.js: <code>{LIBRARY_VERSION}</code>{" "} + · App: <code>{APP_VERSION}</code> + </span> + <span> + <LocaleSelector /> + </span> + </div> + <div className={styles.modal}> + <Switch> + <Route path="/login/create"> + <FormCreate /> + </Route> + <Route path="/login/resend"> + <FormResend /> + </Route> + <Route path="/login/reset/:token"> + <FormReset /> + </Route> + <Route path="/login/reset"> + <FormSendReset /> + </Route> + <Route path="/"> + <FormLogin /> + </Route> + </Switch> + </div> + <div className={styles.attribution}> + <span> + <Text id="general.image_by" /> ‎@lorenzoherrera + ‏· unsplash.com + </span> + </div> + </div> + <div + className={styles.bg} + style={{ background: `url('${background}')` }} + /> + </div> + ); +} diff --git a/src/pages/login/forms/CaptchaBlock.tsx b/src/pages/login/forms/CaptchaBlock.tsx index f435f017e8763dbb0f6a1f046cd7459ef538059b..9cca755ef72ae71f17b0d032e285aa75f2c41831 100644 --- a/src/pages/login/forms/CaptchaBlock.tsx +++ b/src/pages/login/forms/CaptchaBlock.tsx @@ -1,38 +1,41 @@ -import { Text } from "preact-i18n"; -import styles from "../Login.module.scss"; import HCaptcha from "@hcaptcha/react-hcaptcha"; + +import styles from "../Login.module.scss"; +import { Text } from "preact-i18n"; import { useContext, useEffect } from "preact/hooks"; -import Preloader from "../../../components/ui/Preloader"; + import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import Preloader from "../../../components/ui/Preloader"; + export interface CaptchaProps { - onSuccess: (token?: string) => void; - onCancel: () => void; + onSuccess: (token?: string) => void; + onCancel: () => void; } export function CaptchaBlock(props: CaptchaProps) { - const client = useContext(AppContext); - - useEffect(() => { - if (!client.configuration?.features.captcha.enabled) { - props.onSuccess(); - } - }, []); - - if (!client.configuration?.features.captcha.enabled) - return <Preloader type="spinner" />; - - return ( - <div> - <HCaptcha - sitekey={client.configuration.features.captcha.key} - onVerify={token => props.onSuccess(token)} - /> - <div className={styles.footer}> - <a onClick={props.onCancel}> - <Text id="login.cancel" /> - </a> - </div> - </div> - ); + const client = useContext(AppContext); + + useEffect(() => { + if (!client.configuration?.features.captcha.enabled) { + props.onSuccess(); + } + }, []); + + if (!client.configuration?.features.captcha.enabled) + return <Preloader type="spinner" />; + + return ( + <div> + <HCaptcha + sitekey={client.configuration.features.captcha.key} + onVerify={(token) => props.onSuccess(token)} + /> + <div className={styles.footer}> + <a onClick={props.onCancel}> + <Text id="login.cancel" /> + </a> + </div> + </div> + ); } diff --git a/src/pages/login/forms/Form.tsx b/src/pages/login/forms/Form.tsx index 66791ec3529280d13ae99278b1e1b0331b1f00c7..4caf7b9eee43fdce629f269a068a6e41e16028a2 100644 --- a/src/pages/login/forms/Form.tsx +++ b/src/pages/login/forms/Form.tsx @@ -1,244 +1,251 @@ -import { Legal } from "./Legal"; -import { Text } from "preact-i18n"; +import { CheckCircle, Envelope } from "@styled-icons/boxicons-regular"; +import { useForm } from "react-hook-form"; import { Link } from "react-router-dom"; + import styles from "../Login.module.scss"; -import { useForm } from "react-hook-form"; -import { MailProvider } from "./MailProvider"; +import { Text } from "preact-i18n"; import { useContext, useState } from "preact/hooks"; -import { CheckCircle, Envelope } from "@styled-icons/boxicons-regular"; -import { takeError } from "../../../context/revoltjs/util"; -import { CaptchaBlock, CaptchaProps } from "./CaptchaBlock"; + import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { takeError } from "../../../context/revoltjs/util"; -import FormField from "../FormField"; +import wideSVG from "../../../assets/wide.svg"; import Button from "../../../components/ui/Button"; import Overline from "../../../components/ui/Overline"; import Preloader from "../../../components/ui/Preloader"; -import wideSVG from '../../../assets/wide.svg'; +import FormField from "../FormField"; +import { CaptchaBlock, CaptchaProps } from "./CaptchaBlock"; +import { Legal } from "./Legal"; +import { MailProvider } from "./MailProvider"; interface Props { - page: "create" | "login" | "send_reset" | "reset" | "resend"; - callback: (fields: { - email: string; - password: string; - invite: string; - captcha?: string; - }) => Promise<void>; + page: "create" | "login" | "send_reset" | "reset" | "resend"; + callback: (fields: { + email: string; + password: string; + invite: string; + captcha?: string; + }) => Promise<void>; } function getInviteCode() { - if (typeof window === 'undefined') return ''; + if (typeof window === "undefined") return ""; - const urlParams = new URLSearchParams(window.location.search); - const code = urlParams.get('code'); - return code ?? ''; + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get("code"); + return code ?? ""; } interface FormInputs { - email: string - password: string - invite: string + email: string; + password: string; + invite: string; } export function Form({ page, callback }: Props) { - const client = useContext(AppContext); - - const [loading, setLoading] = useState(false); - const [success, setSuccess] = useState<string | undefined>(undefined); - const [error, setGlobalError] = useState<string | undefined>(undefined); - const [captcha, setCaptcha] = useState<CaptchaProps | undefined>(undefined); - - const { handleSubmit, register, errors, setError } = useForm<FormInputs>({ - defaultValues: { - email: '', - password: '', - invite: getInviteCode() - } - }); - - async function onSubmit(data: FormInputs) { - setGlobalError(undefined); - setLoading(true); - - function onError(err: any) { - setLoading(false); - - const error = takeError(err); - switch (error) { - case "email_in_use": - return setError("email", { type: "", message: error }); - case "unknown_user": - return setError("email", { type: "", message: error }); - case "invalid_invite": - return setError("invite", { type: "", message: error }); - } - - setGlobalError(error); - } - - try { - if ( - client.configuration?.features.captcha.enabled && - page !== "reset" - ) { - setCaptcha({ - onSuccess: async captcha => { - setCaptcha(undefined); - try { - await callback({ ...data, captcha }); - setSuccess(data.email); - } catch (err) { - onError(err); - } - }, - onCancel: () => { - setCaptcha(undefined); - setLoading(false); - } - }); - } else { - await callback(data); - setSuccess(data.email); - } - } catch (err) { - onError(err); - } - } - - if (typeof success !== "undefined") { - return ( - <div className={styles.success}> - {client.configuration?.features.email ? ( - <> - <Envelope size={72} /> - <h2> - <Text id="login.check_mail" /> - </h2> - <p className={styles.note}> - <Text id="login.email_delay" /> - </p> - <MailProvider email={success} /> - </> - ) : ( - <> - <CheckCircle size={72} /> - <h1> - <Text id="login.successful_registration" /> - </h1> - </> - )} - <span className={styles.footer}> - <Link to="/login"> - <a> - <Text id="login.remembered" /> - </a> - </Link> - </span> - </div> - ); - } - - if (captcha) return <CaptchaBlock {...captcha} />; - if (loading) return <Preloader type="spinner" />; - - return ( - <div className={styles.form}> - <img src={wideSVG} /> - {/* Preact / React typing incompatabilities */} - <form onSubmit={handleSubmit(onSubmit) as JSX.GenericEventHandler<HTMLFormElement>}> - {page !== "reset" && ( - <FormField - type="email" - register={register} - showOverline - error={errors.email?.message} - /> - )} - {(page === "login" || - page === "create" || - page === "reset") && ( - <FormField - type="password" - register={register} - showOverline - error={errors.password?.message} - /> - )} - {client.configuration?.features.invite_only && - page === "create" && ( - <FormField - type="invite" - register={register} - showOverline - error={errors.invite?.message} - /> - )} - {error && ( - <Overline type="error" error={error}> - <Text id={`login.error.${page}`} /> - </Overline> - )} - <Button> - <Text - id={ - page === "create" - ? "login.register" - : page === "login" - ? "login.title" - : page === "reset" - ? "login.set_password" - : page === "resend" - ? "login.resend" - : "login.reset" - } - /> - </Button> - </form> - {page === "create" && ( - <> - <span className={styles.create}> - <Text id="login.existing" /> - <Link to="/login"> - <Text id="login.title" /> - </Link> - </span> - <span className={styles.create}> - <Text id="login.missing_verification" /> - <Link to="/login/resend"> - <Text id="login.resend" /> - </Link> - </span> - </> - )} - {page === "login" && ( - <> - <span className={styles.create}> - <Text id="login.new" /> - <Link to="/login/create"> - <Text id="login.create" /> - </Link> - </span> - <span className={styles.create}> - <Text id="login.forgot" /> - <Link to="/login/reset"> - <Text id="login.reset" /> - </Link> - </span> - </> - )} - {(page === "reset" || - page === "resend" || - page === "send_reset") && ( - <> - <span className={styles.create}> - <Link to="/login"> - <Text id="login.remembered" /> - </Link> - </span> - </> - )} - <Legal /> - </div> - ); + const client = useContext(AppContext); + + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState<string | undefined>(undefined); + const [error, setGlobalError] = useState<string | undefined>(undefined); + const [captcha, setCaptcha] = useState<CaptchaProps | undefined>(undefined); + + const { handleSubmit, register, errors, setError } = useForm<FormInputs>({ + defaultValues: { + email: "", + password: "", + invite: getInviteCode(), + }, + }); + + async function onSubmit(data: FormInputs) { + setGlobalError(undefined); + setLoading(true); + + function onError(err: any) { + setLoading(false); + + const error = takeError(err); + switch (error) { + case "email_in_use": + return setError("email", { type: "", message: error }); + case "unknown_user": + return setError("email", { type: "", message: error }); + case "invalid_invite": + return setError("invite", { type: "", message: error }); + } + + setGlobalError(error); + } + + try { + if ( + client.configuration?.features.captcha.enabled && + page !== "reset" + ) { + setCaptcha({ + onSuccess: async (captcha) => { + setCaptcha(undefined); + try { + await callback({ ...data, captcha }); + setSuccess(data.email); + } catch (err) { + onError(err); + } + }, + onCancel: () => { + setCaptcha(undefined); + setLoading(false); + }, + }); + } else { + await callback(data); + setSuccess(data.email); + } + } catch (err) { + onError(err); + } + } + + if (typeof success !== "undefined") { + return ( + <div className={styles.success}> + {client.configuration?.features.email ? ( + <> + <Envelope size={72} /> + <h2> + <Text id="login.check_mail" /> + </h2> + <p className={styles.note}> + <Text id="login.email_delay" /> + </p> + <MailProvider email={success} /> + </> + ) : ( + <> + <CheckCircle size={72} /> + <h1> + <Text id="login.successful_registration" /> + </h1> + </> + )} + <span className={styles.footer}> + <Link to="/login"> + <a> + <Text id="login.remembered" /> + </a> + </Link> + </span> + </div> + ); + } + + if (captcha) return <CaptchaBlock {...captcha} />; + if (loading) return <Preloader type="spinner" />; + + return ( + <div className={styles.form}> + <img src={wideSVG} /> + {/* Preact / React typing incompatabilities */} + <form + onSubmit={ + handleSubmit( + onSubmit, + ) as JSX.GenericEventHandler<HTMLFormElement> + }> + {page !== "reset" && ( + <FormField + type="email" + register={register} + showOverline + error={errors.email?.message} + /> + )} + {(page === "login" || + page === "create" || + page === "reset") && ( + <FormField + type="password" + register={register} + showOverline + error={errors.password?.message} + /> + )} + {client.configuration?.features.invite_only && + page === "create" && ( + <FormField + type="invite" + register={register} + showOverline + error={errors.invite?.message} + /> + )} + {error && ( + <Overline type="error" error={error}> + <Text id={`login.error.${page}`} /> + </Overline> + )} + <Button> + <Text + id={ + page === "create" + ? "login.register" + : page === "login" + ? "login.title" + : page === "reset" + ? "login.set_password" + : page === "resend" + ? "login.resend" + : "login.reset" + } + /> + </Button> + </form> + {page === "create" && ( + <> + <span className={styles.create}> + <Text id="login.existing" /> + <Link to="/login"> + <Text id="login.title" /> + </Link> + </span> + <span className={styles.create}> + <Text id="login.missing_verification" /> + <Link to="/login/resend"> + <Text id="login.resend" /> + </Link> + </span> + </> + )} + {page === "login" && ( + <> + <span className={styles.create}> + <Text id="login.new" /> + <Link to="/login/create"> + <Text id="login.create" /> + </Link> + </span> + <span className={styles.create}> + <Text id="login.forgot" /> + <Link to="/login/reset"> + <Text id="login.reset" /> + </Link> + </span> + </> + )} + {(page === "reset" || + page === "resend" || + page === "send_reset") && ( + <> + <span className={styles.create}> + <Link to="/login"> + <Text id="login.remembered" /> + </Link> + </span> + </> + )} + <Legal /> + </div> + ); } diff --git a/src/pages/login/forms/FormCreate.tsx b/src/pages/login/forms/FormCreate.tsx index 4336acd2e92b2d2aac2ae707788df421c584933b..2ea6e323eacd7e0f6dd19a5a5dda8a231ac4b3b3 100644 --- a/src/pages/login/forms/FormCreate.tsx +++ b/src/pages/login/forms/FormCreate.tsx @@ -1,16 +1,18 @@ -import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { useContext } from "preact/hooks"; + +import { AppContext } from "../../../context/revoltjs/RevoltClient"; + import { Form } from "./Form"; export function FormCreate() { - const client = useContext(AppContext); + const client = useContext(AppContext); - return ( - <Form - page="create" - callback={async data => { - await client.register(import.meta.env.VITE_API_URL, data); - }} - /> - ); + return ( + <Form + page="create" + callback={async (data) => { + await client.register(import.meta.env.VITE_API_URL, data); + }} + /> + ); } diff --git a/src/pages/login/forms/FormLogin.tsx b/src/pages/login/forms/FormLogin.tsx index d3af8a18f1250ed404c1edcb7e512f32e1e5bd98..b7ea421ad0f6e216987da266a261faf0a626c3d2 100644 --- a/src/pages/login/forms/FormLogin.tsx +++ b/src/pages/login/forms/FormLogin.tsx @@ -1,29 +1,32 @@ -import { Form } from "./Form"; import { detect } from "detect-browser"; -import { useContext } from "preact/hooks"; import { useHistory } from "react-router-dom"; + +import { useContext } from "preact/hooks"; + import { OperationsContext } from "../../../context/revoltjs/RevoltClient"; +import { Form } from "./Form"; + export function FormLogin() { - const { login } = useContext(OperationsContext); - const history = useHistory(); + const { login } = useContext(OperationsContext); + const history = useHistory(); - return ( - <Form - page="login" - callback={async data => { - const browser = detect(); - let device_name; - if (browser) { - const { name, os } = browser; - device_name = `${name} on ${os}`; - } else { - device_name = "Unknown Device"; - } + return ( + <Form + page="login" + callback={async (data) => { + const browser = detect(); + let device_name; + if (browser) { + const { name, os } = browser; + device_name = `${name} on ${os}`; + } else { + device_name = "Unknown Device"; + } - await login({ ...data, device_name }); - history.push("/"); - }} - /> - ); + await login({ ...data, device_name }); + history.push("/"); + }} + /> + ); } diff --git a/src/pages/login/forms/FormResend.tsx b/src/pages/login/forms/FormResend.tsx index 643767d6f664801924bdb305677e2e1deb75593c..da1e27e5f521fc375a7bbb5e1e61bbe0ee3627ed 100644 --- a/src/pages/login/forms/FormResend.tsx +++ b/src/pages/login/forms/FormResend.tsx @@ -1,16 +1,18 @@ -import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { useContext } from "preact/hooks"; + +import { AppContext } from "../../../context/revoltjs/RevoltClient"; + import { Form } from "./Form"; export function FormResend() { - const client = useContext(AppContext); + const client = useContext(AppContext); - return ( - <Form - page="resend" - callback={async data => { - await client.req("POST", "/auth/resend", data); - }} - /> - ); + return ( + <Form + page="resend" + callback={async (data) => { + await client.req("POST", "/auth/resend", data); + }} + /> + ); } diff --git a/src/pages/login/forms/FormReset.tsx b/src/pages/login/forms/FormReset.tsx index ac73d0186563e03c1e62b9dd4208877a9c8cca17..8e265688f443ba687d0e642575d8fddfc89a6af0 100644 --- a/src/pages/login/forms/FormReset.tsx +++ b/src/pages/login/forms/FormReset.tsx @@ -1,36 +1,39 @@ -import { Form } from "./Form"; -import { useContext } from "preact/hooks"; import { useHistory, useParams } from "react-router-dom"; + +import { useContext } from "preact/hooks"; + import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { Form } from "./Form"; + export function FormSendReset() { - const client = useContext(AppContext); + const client = useContext(AppContext); - return ( - <Form - page="send_reset" - callback={async data => { - await client.req("POST", "/auth/send_reset", data); - }} - /> - ); + return ( + <Form + page="send_reset" + callback={async (data) => { + await client.req("POST", "/auth/send_reset", data); + }} + /> + ); } export function FormReset() { - const { token } = useParams<{ token: string }>(); - const client = useContext(AppContext); - const history = useHistory(); + const { token } = useParams<{ token: string }>(); + const client = useContext(AppContext); + const history = useHistory(); - return ( - <Form - page="reset" - callback={async data => { - await client.req("POST", "/auth/reset", { - token, - ...data - }); - history.push("/login"); - }} - /> - ); + return ( + <Form + page="reset" + callback={async (data) => { + await client.req("POST", "/auth/reset", { + token, + ...data, + }); + history.push("/login"); + }} + /> + ); } diff --git a/src/pages/login/forms/Legal.tsx b/src/pages/login/forms/Legal.tsx index 2517352c1d8bec309364e78af102d63f6cb362f5..754429b8f7f41777ff65ccb932acb823bdefc7e5 100644 --- a/src/pages/login/forms/Legal.tsx +++ b/src/pages/login/forms/Legal.tsx @@ -2,28 +2,19 @@ import styles from "../Login.module.scss"; import { Text } from "preact-i18n"; export function Legal() { - return ( - <span className={styles.footer}> - <a - href="https://revolt.chat/about" - target="_blank" - > - <Text id="general.about" /> - </a> - · - <a - href="https://revolt.chat/terms" - target="_blank" - > - <Text id="general.tos" /> - </a> - · - <a - href="https://revolt.chat/privacy" - target="_blank" - > - <Text id="general.privacy" /> - </a> - </span> - ); + return ( + <span className={styles.footer}> + <a href="https://revolt.chat/about" target="_blank"> + <Text id="general.about" /> + </a> + · + <a href="https://revolt.chat/terms" target="_blank"> + <Text id="general.tos" /> + </a> + · + <a href="https://revolt.chat/privacy" target="_blank"> + <Text id="general.privacy" /> + </a> + </span> + ); } diff --git a/src/pages/login/forms/MailProvider.tsx b/src/pages/login/forms/MailProvider.tsx index 261857ba1dc545fc7ddfeea0e681af4c6f76d6f0..8475f03c4a84cd4b5eab5c8a6db7d29255ee680c 100644 --- a/src/pages/login/forms/MailProvider.tsx +++ b/src/pages/login/forms/MailProvider.tsx @@ -1,55 +1,56 @@ -import { Text } from "preact-i18n"; import styles from "../Login.module.scss"; +import { Text } from "preact-i18n"; + import Button from "../../../components/ui/Button"; interface Props { - email?: string; + email?: string; } function mapMailProvider(email?: string): [string, string] | undefined { - if (!email) return; + if (!email) return; - const match = /@(.+)/.exec(email); - if (match === null) return; + const match = /@(.+)/.exec(email); + if (match === null) return; - const domain = match[1]; - switch (domain) { - case "gmail.com": - return ["Gmail", "https://gmail.com"]; - case "tuta.io": - return ["Tutanota", "https://mail.tutanota.com"]; - case "outlook.com": - return ["Outlook", "https://outlook.live.com"]; - case "yahoo.com": - return ["Yahoo", "https://mail.yahoo.com"]; - case "wp.pl": - return ["WP Poczta", "https://poczta.wp.pl"]; - case "protonmail.com": - case "protonmail.ch": - return ["ProtonMail", "https://mail.protonmail.com"]; - case "seznam.cz": - case "email.cz": - case "post.cz": - return ["Seznam", "https://email.seznam.cz"]; - default: - return [domain, `https://${domain}`]; - } + const domain = match[1]; + switch (domain) { + case "gmail.com": + return ["Gmail", "https://gmail.com"]; + case "tuta.io": + return ["Tutanota", "https://mail.tutanota.com"]; + case "outlook.com": + return ["Outlook", "https://outlook.live.com"]; + case "yahoo.com": + return ["Yahoo", "https://mail.yahoo.com"]; + case "wp.pl": + return ["WP Poczta", "https://poczta.wp.pl"]; + case "protonmail.com": + case "protonmail.ch": + return ["ProtonMail", "https://mail.protonmail.com"]; + case "seznam.cz": + case "email.cz": + case "post.cz": + return ["Seznam", "https://email.seznam.cz"]; + default: + return [domain, `https://${domain}`]; + } } export function MailProvider({ email }: Props) { - const provider = mapMailProvider(email); - if (!provider) return null; + const provider = mapMailProvider(email); + if (!provider) return null; - return ( - <div className={styles.mailProvider}> - <a href={provider[1]} target="_blank"> - <Button> - <Text - id="login.open_mail_provider" - fields={{ provider: provider[0] }} - /> - </Button> - </a> - </div> - ); + return ( + <div className={styles.mailProvider}> + <a href={provider[1]} target="_blank"> + <Button> + <Text + id="login.open_mail_provider" + fields={{ provider: provider[0] }} + /> + </Button> + </a> + </div> + ); } diff --git a/src/pages/settings/ChannelSettings.tsx b/src/pages/settings/ChannelSettings.tsx index aed5ca6950b2c6164bb5c516382e3609611064e7..6720af598aee77cb8552bf5bffa758f1b43c8c5c 100644 --- a/src/pages/settings/ChannelSettings.tsx +++ b/src/pages/settings/ChannelSettings.tsx @@ -1,62 +1,87 @@ -import { Text } from "preact-i18n"; -import Category from "../../components/ui/Category"; -import { GenericSettings } from "./GenericSettings"; -import { getChannelName } from "../../context/revoltjs/util"; -import { Route, useHistory, useParams } from "react-router-dom"; import { ListCheck, ListUl } from "@styled-icons/boxicons-regular"; +import { Route, useHistory, useParams } from "react-router-dom"; + +import { Text } from "preact-i18n"; + import { useChannel, useForceUpdate } from "../../context/revoltjs/hooks"; +import { getChannelName } from "../../context/revoltjs/util"; + +import Category from "../../components/ui/Category"; +import { GenericSettings } from "./GenericSettings"; import Overview from "./channel/Overview"; import Permissions from "./channel/Permissions"; export default function ChannelSettings() { - const { channel: cid } = useParams<{ channel: string; }>(); - const ctx = useForceUpdate(); - const channel = useChannel(cid, ctx); - if (!channel) return null; - if (channel.channel_type === 'SavedMessages' || channel.channel_type === 'DirectMessage') return null; - - const history = useHistory(); - function switchPage(to?: string) { - let base_url; - switch (channel?.channel_type) { - case 'TextChannel': - case 'VoiceChannel': base_url = `/server/${channel.server}/channel/${cid}/settings`; break; - default: base_url = `/channel/${cid}/settings`; - } - - if (to) { - history.replace(`${base_url}/${to}`); - } else { - history.replace(base_url); - } - } - - return ( - <GenericSettings - pages={[ - { - category: <Category variant="uniform" text={getChannelName(ctx.client, channel, true)} />, - id: 'overview', - icon: <ListUl size={20} />, - title: <Text id="app.settings.channel_pages.overview.title" /> - }, - { - id: 'permissions', - icon: <ListCheck size={20} />, - title: <Text id="app.settings.channel_pages.permissions.title" /> - } - ]} - children={[ - <Route path="/server/:server/channel/:channel/settings/permissions"><Permissions channel={channel} /></Route>, - <Route path="/channel/:channel/settings/permissions"><Permissions channel={channel} /></Route>, - - <Route path="/"><Overview channel={channel} /></Route> - ]} - category="channel_pages" - switchPage={switchPage} - defaultPage="overview" - showExitButton - /> - ) + const { channel: cid } = useParams<{ channel: string }>(); + const ctx = useForceUpdate(); + const channel = useChannel(cid, ctx); + if (!channel) return null; + if ( + channel.channel_type === "SavedMessages" || + channel.channel_type === "DirectMessage" + ) + return null; + + const history = useHistory(); + function switchPage(to?: string) { + let base_url; + switch (channel?.channel_type) { + case "TextChannel": + case "VoiceChannel": + base_url = `/server/${channel.server}/channel/${cid}/settings`; + break; + default: + base_url = `/channel/${cid}/settings`; + } + + if (to) { + history.replace(`${base_url}/${to}`); + } else { + history.replace(base_url); + } + } + + return ( + <GenericSettings + pages={[ + { + category: ( + <Category + variant="uniform" + text={getChannelName(ctx.client, channel, true)} + /> + ), + id: "overview", + icon: <ListUl size={20} />, + title: ( + <Text id="app.settings.channel_pages.overview.title" /> + ), + }, + { + id: "permissions", + icon: <ListCheck size={20} />, + title: ( + <Text id="app.settings.channel_pages.permissions.title" /> + ), + }, + ]} + children={[ + <Route path="/server/:server/channel/:channel/settings/permissions"> + <Permissions channel={channel} /> + </Route>, + <Route path="/channel/:channel/settings/permissions"> + <Permissions channel={channel} /> + </Route>, + + <Route path="/"> + <Overview channel={channel} /> + </Route>, + ]} + category="channel_pages" + switchPage={switchPage} + defaultPage="overview" + showExitButton + /> + ); } diff --git a/src/pages/settings/GenericSettings.tsx b/src/pages/settings/GenericSettings.tsx index 484ea683cf6861b03aeb11e53bd88e477e599995..2f64ecc4d86790a165a160aac4885316ddc7a937 100644 --- a/src/pages/settings/GenericSettings.tsx +++ b/src/pages/settings/GenericSettings.tsx @@ -1,134 +1,158 @@ -import { Text } from "preact-i18n"; +import { ArrowBack, X, XCircle } from "@styled-icons/boxicons-regular"; import { Helmet } from "react-helmet"; +import { Switch, useHistory, useParams } from "react-router-dom"; + import styles from "./Settings.module.scss"; -import { Children } from "../../types/Preact"; -import Header from '../../components/ui/Header'; -import { ThemeContext } from "../../context/Theme"; -import Category from '../../components/ui/Category'; +import { Text } from "preact-i18n"; import { useContext, useEffect } from "preact/hooks"; + +import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; + +import { ThemeContext } from "../../context/Theme"; + +import Category from "../../components/ui/Category"; +import Header from "../../components/ui/Header"; import IconButton from "../../components/ui/IconButton"; import LineDivider from "../../components/ui/LineDivider"; -import { Switch, useHistory, useParams } from "react-router-dom"; -import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; + import ButtonItem from "../../components/navigation/items/ButtonItem"; -import { ArrowBack, X, XCircle } from "@styled-icons/boxicons-regular"; +import { Children } from "../../types/Preact"; interface Props { - pages: { - category?: Children - divider?: boolean - id: string - icon: Children - title: Children - hideTitle?: boolean - }[] - custom?: Children - children: Children - defaultPage: string - showExitButton?: boolean - switchPage: (to?: string) => void - category: 'pages' | 'channel_pages' | 'server_pages' + pages: { + category?: Children; + divider?: boolean; + id: string; + icon: Children; + title: Children; + hideTitle?: boolean; + }[]; + custom?: Children; + children: Children; + defaultPage: string; + showExitButton?: boolean; + switchPage: (to?: string) => void; + category: "pages" | "channel_pages" | "server_pages"; } -export function GenericSettings({ pages, switchPage, category, custom, children, defaultPage, showExitButton }: Props) { - const history = useHistory(); - const theme = useContext(ThemeContext); - const { page } = useParams<{ page: string; }>(); +export function GenericSettings({ + pages, + switchPage, + category, + custom, + children, + defaultPage, + showExitButton, +}: Props) { + const history = useHistory(); + const theme = useContext(ThemeContext); + const { page } = useParams<{ page: string }>(); - function exitSettings() { - if (history.length > 0) { - history.goBack(); - } else { - history.push('/'); - } - } + function exitSettings() { + if (history.length > 0) { + history.goBack(); + } else { + history.push("/"); + } + } - useEffect(() => { - function keyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - exitSettings(); - } - } + useEffect(() => { + function keyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + exitSettings(); + } + } - document.body.addEventListener("keydown", keyDown); - return () => document.body.removeEventListener("keydown", keyDown); - }, []); + document.body.addEventListener("keydown", keyDown); + return () => document.body.removeEventListener("keydown", keyDown); + }, []); - return ( - <div className={styles.settings} data-mobile={isTouchscreenDevice}> - <Helmet> - <meta - name="theme-color" - content={ - isTouchscreenDevice - ? theme["primary-header"] - : theme["secondary-background"] - } - /> - </Helmet> - {isTouchscreenDevice && ( - <Header placement="primary"> - {typeof page === "undefined" ? ( - <> - { showExitButton && - <IconButton onClick={exitSettings}> - <X size={24} /> - </IconButton> } - <Text id="app.settings.title" /> - </> - ) : ( - <> - <IconButton onClick={() => switchPage()}> - <ArrowBack size={24} /> - </IconButton> - <Text - id={`app.settings.${category}.${page}.title`} - /> - </> - )} - </Header> - )} - {(!isTouchscreenDevice || typeof page === "undefined") && ( - <div className={styles.sidebar}> - <div className={styles.container}> - { - pages.map((entry, i) => - <> - { entry.category && <Category variant="uniform" text={entry.category} /> } - <ButtonItem - active={page === entry.id || (i === 0 && !isTouchscreenDevice && typeof page === "undefined")} - onClick={() => switchPage(entry.id)} - compact - >{entry.icon} {entry.title}</ButtonItem> - { entry.divider && <LineDivider /> } - </> - ) - } - { custom } - </div> - </div> - )} - {(!isTouchscreenDevice || typeof page === "string") && ( - <div className={styles.content}> - {!isTouchscreenDevice && !(pages.find(x => x.id === page && x.hideTitle)) && ( - <h1> - <Text - id={`app.settings.${category}.${page ?? defaultPage}.title`} - /> - </h1> - )} - <Switch> - { children } - </Switch> - </div> - )} - {!isTouchscreenDevice && ( - <div className={styles.action}> - <IconButton onClick={exitSettings}> - <XCircle size={48} /> - </IconButton> - </div> - )} - </div> - ); + return ( + <div className={styles.settings} data-mobile={isTouchscreenDevice}> + <Helmet> + <meta + name="theme-color" + content={ + isTouchscreenDevice + ? theme["primary-header"] + : theme["secondary-background"] + } + /> + </Helmet> + {isTouchscreenDevice && ( + <Header placement="primary"> + {typeof page === "undefined" ? ( + <> + {showExitButton && ( + <IconButton onClick={exitSettings}> + <X size={24} /> + </IconButton> + )} + <Text id="app.settings.title" /> + </> + ) : ( + <> + <IconButton onClick={() => switchPage()}> + <ArrowBack size={24} /> + </IconButton> + <Text + id={`app.settings.${category}.${page}.title`} + /> + </> + )} + </Header> + )} + {(!isTouchscreenDevice || typeof page === "undefined") && ( + <div className={styles.sidebar}> + <div className={styles.container}> + {pages.map((entry, i) => ( + <> + {entry.category && ( + <Category + variant="uniform" + text={entry.category} + /> + )} + <ButtonItem + active={ + page === entry.id || + (i === 0 && + !isTouchscreenDevice && + typeof page === "undefined") + } + onClick={() => switchPage(entry.id)} + compact> + {entry.icon} {entry.title} + </ButtonItem> + {entry.divider && <LineDivider />} + </> + ))} + {custom} + </div> + </div> + )} + {(!isTouchscreenDevice || typeof page === "string") && ( + <div className={styles.content}> + {!isTouchscreenDevice && + !pages.find((x) => x.id === page && x.hideTitle) && ( + <h1> + <Text + id={`app.settings.${category}.${ + page ?? defaultPage + }.title`} + /> + </h1> + )} + <Switch>{children}</Switch> + </div> + )} + {!isTouchscreenDevice && ( + <div className={styles.action}> + <IconButton onClick={exitSettings}> + <XCircle size={48} /> + </IconButton> + </div> + )} + </div> + ); } diff --git a/src/pages/settings/ServerSettings.tsx b/src/pages/settings/ServerSettings.tsx index 2c5148a7246250526735325fe25103696a72f048..bb0cde830583f8f41d25d7ae3081bce59451a6ad 100644 --- a/src/pages/settings/ServerSettings.tsx +++ b/src/pages/settings/ServerSettings.tsx @@ -1,74 +1,106 @@ -import { Text } from "preact-i18n"; -import Category from "../../components/ui/Category"; -import { GenericSettings } from "./GenericSettings"; -import { useServer } from "../../context/revoltjs/hooks"; -import { Route, useHistory, useParams } from "react-router-dom"; -import { ListUl, Share, Group, ListCheck } from "@styled-icons/boxicons-regular"; +import { + ListUl, + Share, + Group, + ListCheck, +} from "@styled-icons/boxicons-regular"; import { XSquare } from "@styled-icons/boxicons-solid"; +import { Route, useHistory, useParams } from "react-router-dom"; + +import { Text } from "preact-i18n"; + import RequiresOnline from "../../context/revoltjs/RequiresOnline"; +import { useServer } from "../../context/revoltjs/hooks"; -import { Overview } from "./server/Overview"; -import { Members } from "./server/Members"; -import { Invites } from "./server/Invites"; +import Category from "../../components/ui/Category"; + +import { GenericSettings } from "./GenericSettings"; import { Bans } from "./server/Bans"; +import { Invites } from "./server/Invites"; +import { Members } from "./server/Members"; +import { Overview } from "./server/Overview"; import { Roles } from "./server/Roles"; export default function ServerSettings() { - const { server: sid } = useParams<{ server: string; }>(); - const server = useServer(sid); - if (!server) return null; + const { server: sid } = useParams<{ server: string }>(); + const server = useServer(sid); + if (!server) return null; - const history = useHistory(); - function switchPage(to?: string) { - if (to) { - history.replace(`/server/${sid}/settings/${to}`); - } else { - history.replace(`/server/${sid}/settings`); - } - } + const history = useHistory(); + function switchPage(to?: string) { + if (to) { + history.replace(`/server/${sid}/settings/${to}`); + } else { + history.replace(`/server/${sid}/settings`); + } + } - return ( - <GenericSettings - pages={[ - { - category: <Category variant="uniform" text={server.name} />, //TOFIX: Just add the server.name as a string, otherwise it makes a duplicate category - id: 'overview', - icon: <ListUl size={20} />, - title: <Text id="app.settings.server_pages.overview.title" /> - }, - { - id: 'members', - icon: <Group size={20} />, - title: <Text id="app.settings.server_pages.members.title" /> - }, - { - id: 'invites', - icon: <Share size={20} />, - title: <Text id="app.settings.server_pages.invites.title" /> - }, - { - id: 'bans', - icon: <XSquare size={20} />, - title: <Text id="app.settings.server_pages.bans.title" /> - }, - { - id: 'roles', - icon: <ListCheck size={20} />, - title: <Text id="app.settings.server_pages.roles.title" />, - hideTitle: true - } - ]} - children={[ - <Route path="/server/:server/settings/members"><RequiresOnline><Members server={server} /></RequiresOnline></Route>, - <Route path="/server/:server/settings/invites"><RequiresOnline><Invites server={server} /></RequiresOnline></Route>, - <Route path="/server/:server/settings/bans"><RequiresOnline><Bans server={server} /></RequiresOnline></Route>, - <Route path="/server/:server/settings/roles"><RequiresOnline><Roles server={server} /></RequiresOnline></Route>, - <Route path="/"><Overview server={server} /></Route> - ]} - category="server_pages" - switchPage={switchPage} - defaultPage="overview" - showExitButton - /> - ) + return ( + <GenericSettings + pages={[ + { + category: <Category variant="uniform" text={server.name} />, //TOFIX: Just add the server.name as a string, otherwise it makes a duplicate category + id: "overview", + icon: <ListUl size={20} />, + title: ( + <Text id="app.settings.server_pages.overview.title" /> + ), + }, + { + id: "members", + icon: <Group size={20} />, + title: ( + <Text id="app.settings.server_pages.members.title" /> + ), + }, + { + id: "invites", + icon: <Share size={20} />, + title: ( + <Text id="app.settings.server_pages.invites.title" /> + ), + }, + { + id: "bans", + icon: <XSquare size={20} />, + title: <Text id="app.settings.server_pages.bans.title" />, + }, + { + id: "roles", + icon: <ListCheck size={20} />, + title: <Text id="app.settings.server_pages.roles.title" />, + hideTitle: true, + }, + ]} + children={[ + <Route path="/server/:server/settings/members"> + <RequiresOnline> + <Members server={server} /> + </RequiresOnline> + </Route>, + <Route path="/server/:server/settings/invites"> + <RequiresOnline> + <Invites server={server} /> + </RequiresOnline> + </Route>, + <Route path="/server/:server/settings/bans"> + <RequiresOnline> + <Bans server={server} /> + </RequiresOnline> + </Route>, + <Route path="/server/:server/settings/roles"> + <RequiresOnline> + <Roles server={server} /> + </RequiresOnline> + </Route>, + <Route path="/"> + <Overview server={server} /> + </Route>, + ]} + category="server_pages" + switchPage={switchPage} + defaultPage="overview" + showExitButton + /> + ); } diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx index 4fa2b5827333b1fb9f186fe5176263182f3a0e92..fba14e3b1567973bd10508543cb7d582cc14a9d2 100644 --- a/src/pages/settings/Settings.tsx +++ b/src/pages/settings/Settings.tsx @@ -1,160 +1,202 @@ +import { Gitlab } from "@styled-icons/boxicons-logos"; +import { + Sync as SyncIcon, + Globe, + LogOut, +} from "@styled-icons/boxicons-regular"; +import { + Bell, + Palette, + Coffee, + IdCard, + CheckShield, + Flask, + User, + Megaphone, +} from "@styled-icons/boxicons-solid"; +import { Route, useHistory } from "react-router-dom"; +import { LIBRARY_VERSION } from "revolt.js"; + +import styles from "./Settings.module.scss"; import { Text } from "preact-i18n"; -import { Sync } from "./panes/Sync"; import { useContext } from "preact/hooks"; -import styles from "./Settings.module.scss"; -import { LIBRARY_VERSION } from "revolt.js"; -import { APP_VERSION } from "../../version"; -import { GenericSettings } from "./GenericSettings"; -import { Route, useHistory } from "react-router-dom"; + +import RequiresOnline from "../../context/revoltjs/RequiresOnline"; import { - Bell, - Palette, - Coffee, - IdCard, - CheckShield, - Flask, - User, - Megaphone -} from "@styled-icons/boxicons-solid"; -import { Sync as SyncIcon, Globe, LogOut } from "@styled-icons/boxicons-regular"; -import { Gitlab } from "@styled-icons/boxicons-logos"; -import { GIT_BRANCH, GIT_REVISION, REPO_URL } from "../../revision"; + AppContext, + OperationsContext, +} from "../../context/revoltjs/RevoltClient"; + import LineDivider from "../../components/ui/LineDivider"; -import RequiresOnline from "../../context/revoltjs/RequiresOnline"; + import ButtonItem from "../../components/navigation/items/ButtonItem"; -import { AppContext, OperationsContext } from "../../context/revoltjs/RevoltClient"; +import { GIT_BRANCH, GIT_REVISION, REPO_URL } from "../../revision"; +import { APP_VERSION } from "../../version"; +import { GenericSettings } from "./GenericSettings"; import { Account } from "./panes/Account"; -import { Profile } from "./panes/Profile"; -import { Sessions } from "./panes/Sessions"; +import { Appearance } from "./panes/Appearance"; +import { ExperimentsPage } from "./panes/Experiments"; import { Feedback } from "./panes/Feedback"; import { Languages } from "./panes/Languages"; -import { Appearance } from "./panes/Appearance"; import { Notifications } from "./panes/Notifications"; -import { ExperimentsPage } from "./panes/Experiments"; +import { Profile } from "./panes/Profile"; +import { Sessions } from "./panes/Sessions"; +import { Sync } from "./panes/Sync"; export default function Settings() { - const history = useHistory(); - const client = useContext(AppContext); - const operations = useContext(OperationsContext); - - function switchPage(to?: string) { - if (to) { - history.replace(`/settings/${to}`); - } else { - history.replace(`/settings`); - } - } + const history = useHistory(); + const client = useContext(AppContext); + const operations = useContext(OperationsContext); + + function switchPage(to?: string) { + if (to) { + history.replace(`/settings/${to}`); + } else { + history.replace(`/settings`); + } + } - return ( - <GenericSettings - pages={[ - { - category: <Text id="app.settings.categories.user_settings" />, - id: 'account', - icon: <User size={20} />, - title: <Text id="app.settings.pages.account.title" /> - }, - { - id: 'profile', - icon: <IdCard size={20} />, - title: <Text id="app.settings.pages.profile.title" /> - }, - { - id: 'sessions', - icon: <CheckShield size={20} />, - title: <Text id="app.settings.pages.sessions.title" /> - }, - { - category: <Text id="app.settings.categories.client_settings" />, - id: 'appearance', - icon: <Palette size={20} />, - title: <Text id="app.settings.pages.appearance.title" /> - }, - { - id: 'notifications', - icon: <Bell size={20} />, - title: <Text id="app.settings.pages.notifications.title" /> - }, - { - id: 'language', - icon: <Globe size={20} />, - title: <Text id="app.settings.pages.language.title" /> - }, - { - id: 'sync', - icon: <SyncIcon size={20} />, - title: <Text id="app.settings.pages.sync.title" /> - }, - { - divider: true, - id: 'experiments', - icon: <Flask size={20} />, - title: <Text id="app.settings.pages.experiments.title" /> - }, - { - id: 'feedback', - icon: <Megaphone size={20} />, - title: <Text id="app.settings.pages.feedback.title" /> - } - ]} - children={[ - <Route path="/settings/profile"><Profile /></Route>, - <Route path="/settings/sessions"> - <RequiresOnline><Sessions /></RequiresOnline> - </Route>, - <Route path="/settings/appearance"><Appearance /></Route>, - <Route path="/settings/notifications"><Notifications /></Route>, - <Route path="/settings/language"><Languages /></Route>, - <Route path="/settings/sync"><Sync /></Route>, - <Route path="/settings/experiments"><ExperimentsPage /></Route>, - <Route path="/settings/feedback"><Feedback /></Route>, - <Route path="/"><Account /></Route> - ]} - defaultPage="account" - switchPage={switchPage} - category="pages" - custom={[ - <a - href="https://gitlab.insrt.uk/revolt" - target="_blank" - > - <ButtonItem compact> - <Gitlab size={20} /> - <Text id="app.settings.pages.source_code" /> - </ButtonItem> - </a>, - <a href="https://ko-fi.com/insertish" target="_blank"> - <ButtonItem className={styles.donate} compact> - <Coffee size={20} /> - <Text id="app.settings.pages.donate.title" /> - </ButtonItem> - </a>, - <LineDivider />, - <ButtonItem - onClick={() => operations.logout()} - className={styles.logOut} - compact - > - <LogOut size={20} /> - <Text id="app.settings.pages.logOut" /> - </ButtonItem>, - <div className={styles.version}> - <div> - <span className={styles.revision}> - <a href={`${REPO_URL}/${GIT_REVISION}`} target="_blank"> - { GIT_REVISION.substr(0, 7) } - </a> - {` `} - <a href={GIT_BRANCH !== 'DETACHED' ? `https://gitlab.insrt.uk/revolt/client/-/tree/${GIT_BRANCH}` : undefined} target="_blank"> - ({ GIT_BRANCH }) - </a> - </span> - <span>{ GIT_BRANCH === 'production' ? 'Stable' : 'Nightly' } {APP_VERSION}</span> - <span>API: {client.configuration?.revolt ?? "N/A"}</span> - <span>revolt.js: {LIBRARY_VERSION}</span> - </div> - </div> - ]} - /> - ) + return ( + <GenericSettings + pages={[ + { + category: ( + <Text id="app.settings.categories.user_settings" /> + ), + id: "account", + icon: <User size={20} />, + title: <Text id="app.settings.pages.account.title" />, + }, + { + id: "profile", + icon: <IdCard size={20} />, + title: <Text id="app.settings.pages.profile.title" />, + }, + { + id: "sessions", + icon: <CheckShield size={20} />, + title: <Text id="app.settings.pages.sessions.title" />, + }, + { + category: ( + <Text id="app.settings.categories.client_settings" /> + ), + id: "appearance", + icon: <Palette size={20} />, + title: <Text id="app.settings.pages.appearance.title" />, + }, + { + id: "notifications", + icon: <Bell size={20} />, + title: <Text id="app.settings.pages.notifications.title" />, + }, + { + id: "language", + icon: <Globe size={20} />, + title: <Text id="app.settings.pages.language.title" />, + }, + { + id: "sync", + icon: <SyncIcon size={20} />, + title: <Text id="app.settings.pages.sync.title" />, + }, + { + divider: true, + id: "experiments", + icon: <Flask size={20} />, + title: <Text id="app.settings.pages.experiments.title" />, + }, + { + id: "feedback", + icon: <Megaphone size={20} />, + title: <Text id="app.settings.pages.feedback.title" />, + }, + ]} + children={[ + <Route path="/settings/profile"> + <Profile /> + </Route>, + <Route path="/settings/sessions"> + <RequiresOnline> + <Sessions /> + </RequiresOnline> + </Route>, + <Route path="/settings/appearance"> + <Appearance /> + </Route>, + <Route path="/settings/notifications"> + <Notifications /> + </Route>, + <Route path="/settings/language"> + <Languages /> + </Route>, + <Route path="/settings/sync"> + <Sync /> + </Route>, + <Route path="/settings/experiments"> + <ExperimentsPage /> + </Route>, + <Route path="/settings/feedback"> + <Feedback /> + </Route>, + <Route path="/"> + <Account /> + </Route>, + ]} + defaultPage="account" + switchPage={switchPage} + category="pages" + custom={[ + <a href="https://gitlab.insrt.uk/revolt" target="_blank"> + <ButtonItem compact> + <Gitlab size={20} /> + <Text id="app.settings.pages.source_code" /> + </ButtonItem> + </a>, + <a href="https://ko-fi.com/insertish" target="_blank"> + <ButtonItem className={styles.donate} compact> + <Coffee size={20} /> + <Text id="app.settings.pages.donate.title" /> + </ButtonItem> + </a>, + <LineDivider />, + <ButtonItem + onClick={() => operations.logout()} + className={styles.logOut} + compact> + <LogOut size={20} /> + <Text id="app.settings.pages.logOut" /> + </ButtonItem>, + <div className={styles.version}> + <div> + <span className={styles.revision}> + <a + href={`${REPO_URL}/${GIT_REVISION}`} + target="_blank"> + {GIT_REVISION.substr(0, 7)} + </a> + {` `} + <a + href={ + GIT_BRANCH !== "DETACHED" + ? `https://gitlab.insrt.uk/revolt/client/-/tree/${GIT_BRANCH}` + : undefined + } + target="_blank"> + ({GIT_BRANCH}) + </a> + </span> + <span> + {GIT_BRANCH === "production" ? "Stable" : "Nightly"}{" "} + {APP_VERSION} + </span> + <span> + API: {client.configuration?.revolt ?? "N/A"} + </span> + <span>revolt.js: {LIBRARY_VERSION}</span> + </div> + </div>, + ]} + /> + ); } diff --git a/src/pages/settings/channel/Overview.tsx b/src/pages/settings/channel/Overview.tsx index f41f123bd45242c04cf6556e68a4b03900d62a70..6df4e5a8a389bbca340476e6cec376d6c1a871e2 100644 --- a/src/pages/settings/channel/Overview.tsx +++ b/src/pages/settings/channel/Overview.tsx @@ -1,91 +1,117 @@ -import { Text } from "preact-i18n"; -import styles from "./Panes.module.scss"; -import Button from "../../../components/ui/Button"; import { Channels } from "revolt.js/dist/api/objects"; -import InputBox from "../../../components/ui/InputBox"; -import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; + +import styles from "./Panes.module.scss"; +import { Text } from "preact-i18n"; import { useContext, useEffect, useState } from "preact/hooks"; -import { AppContext } from "../../../context/revoltjs/RevoltClient"; + +import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; + import { FileUploader } from "../../../context/revoltjs/FileUploads"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; + +import Button from "../../../components/ui/Button"; +import InputBox from "../../../components/ui/InputBox"; interface Props { - channel: Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel; + channel: + | Channels.GroupChannel + | Channels.TextChannel + | Channels.VoiceChannel; } export default function Overview({ channel }: Props) { - const client = useContext(AppContext); + const client = useContext(AppContext); - const [name, setName] = useState(channel.name); - const [description, setDescription] = useState(channel.description ?? ''); + const [name, setName] = useState(channel.name); + const [description, setDescription] = useState(channel.description ?? ""); - useEffect(() => setName(channel.name), [ channel.name ]); - useEffect(() => setDescription(channel.description ?? ''), [ channel.description ]); + useEffect(() => setName(channel.name), [channel.name]); + useEffect( + () => setDescription(channel.description ?? ""), + [channel.description], + ); - const [ changed, setChanged ] = useState(false); - function save() { - let changes: any = {}; - if (name !== channel.name) changes.name = name; - if (description !== channel.description) - changes.description = description; + const [changed, setChanged] = useState(false); + function save() { + let changes: any = {}; + if (name !== channel.name) changes.name = name; + if (description !== channel.description) + changes.description = description; - client.channels.edit(channel._id, changes); - setChanged(false); - } + client.channels.edit(channel._id, changes); + setChanged(false); + } - return ( - <div className={styles.overview}> - <div className={styles.row}> - <FileUploader - width={80} - height={80} - style="icon" - fileType="icons" - behaviour="upload" - maxFileSize={2_500_000} - onUpload={icon => client.channels.edit(channel._id, { icon })} - previewURL={client.channels.getIconURL(channel._id, { max_side: 256 }, true)} - remove={() => client.channels.edit(channel._id, { remove: 'Icon' })} - defaultPreview={channel.channel_type === 'Group' ? "/assets/group.png" : undefined} - /> - <div className={styles.name}> - <h3> - { channel.channel_type === 'Group' ? - <Text id="app.main.groups.name" /> : - <Text id="app.main.servers.channel_name" /> } - </h3> - <InputBox - contrast - value={name} - maxLength={32} - onChange={e => { - setName(e.currentTarget.value) - if (!changed) setChanged(true) - }} - /> - </div> - </div> + return ( + <div className={styles.overview}> + <div className={styles.row}> + <FileUploader + width={80} + height={80} + style="icon" + fileType="icons" + behaviour="upload" + maxFileSize={2_500_000} + onUpload={(icon) => + client.channels.edit(channel._id, { icon }) + } + previewURL={client.channels.getIconURL( + channel._id, + { max_side: 256 }, + true, + )} + remove={() => + client.channels.edit(channel._id, { remove: "Icon" }) + } + defaultPreview={ + channel.channel_type === "Group" + ? "/assets/group.png" + : undefined + } + /> + <div className={styles.name}> + <h3> + {channel.channel_type === "Group" ? ( + <Text id="app.main.groups.name" /> + ) : ( + <Text id="app.main.servers.channel_name" /> + )} + </h3> + <InputBox + contrast + value={name} + maxLength={32} + onChange={(e) => { + setName(e.currentTarget.value); + if (!changed) setChanged(true); + }} + /> + </div> + </div> - <h3> - { channel.channel_type === 'Group' ? - <Text id="app.main.groups.description" /> : - <Text id="app.main.servers.channel_description" /> } - </h3> - <TextAreaAutoSize - maxRows={10} - minHeight={60} - maxLength={1024} - value={description} - placeholder={"Add a description..."} - onChange={ev => { - setDescription(ev.currentTarget.value); - if (!changed) setChanged(true) - }} - /> - <p> - <Button onClick={save} contrast disabled={!changed}> - <Text id="app.special.modals.actions.save" /> - </Button> - </p> - </div> - ); + <h3> + {channel.channel_type === "Group" ? ( + <Text id="app.main.groups.description" /> + ) : ( + <Text id="app.main.servers.channel_description" /> + )} + </h3> + <TextAreaAutoSize + maxRows={10} + minHeight={60} + maxLength={1024} + value={description} + placeholder={"Add a description..."} + onChange={(ev) => { + setDescription(ev.currentTarget.value); + if (!changed) setChanged(true); + }} + /> + <p> + <Button onClick={save} contrast disabled={!changed}> + <Text id="app.special.modals.actions.save" /> + </Button> + </p> + </div> + ); } diff --git a/src/pages/settings/channel/Permissions.tsx b/src/pages/settings/channel/Permissions.tsx index 7fc8c2e694790082a7c4cc103d3fc8e02dc9ba38..c5467215631b7616279cbdf186e9a75c7e7c3027 100644 --- a/src/pages/settings/channel/Permissions.tsx +++ b/src/pages/settings/channel/Permissions.tsx @@ -1,91 +1,111 @@ -import Tip from "../../../components/ui/Tip"; -import Button from "../../../components/ui/Button"; import { Channels } from "revolt.js/dist/api/objects"; -import Checkbox from "../../../components/ui/Checkbox"; -import { useServer } from "../../../context/revoltjs/hooks"; -import { useContext, useEffect, useState } from "preact/hooks"; import { ChannelPermission } from "revolt.js/dist/api/permissions"; + +import { useContext, useEffect, useState } from "preact/hooks"; + import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { useServer } from "../../../context/revoltjs/hooks"; + +import Button from "../../../components/ui/Button"; +import Checkbox from "../../../components/ui/Checkbox"; +import Tip from "../../../components/ui/Tip"; // ! FIXME: export from revolt.js -const DEFAULT_PERMISSION_DM = ChannelPermission.View - + ChannelPermission.SendMessage - + ChannelPermission.ManageChannel - + ChannelPermission.VoiceCall - + ChannelPermission.InviteOthers - + ChannelPermission.EmbedLinks - + ChannelPermission.UploadFiles; +const DEFAULT_PERMISSION_DM = + ChannelPermission.View + + ChannelPermission.SendMessage + + ChannelPermission.ManageChannel + + ChannelPermission.VoiceCall + + ChannelPermission.InviteOthers + + ChannelPermission.EmbedLinks + + ChannelPermission.UploadFiles; interface Props { - channel: Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel; + channel: + | Channels.GroupChannel + | Channels.TextChannel + | Channels.VoiceChannel; } // ! FIXME: bad code :) export default function Permissions({ channel }: Props) { - const [ selected, setSelected ] = useState('default'); - const client = useContext(AppContext); + const [selected, setSelected] = useState("default"); + const client = useContext(AppContext); + + type R = { name: string; permissions: number }; + let roles: { [key: string]: R } = {}; + if (channel.channel_type !== "Group") { + const server = useServer(channel.server); + const a = server?.roles ?? {}; + for (let b of Object.keys(a)) { + roles[b] = { + name: a[b].name, + permissions: a[b].permissions[1], + }; + } + } - type R = { name: string, permissions: number }; - let roles: { [key: string]: R } = {}; - if (channel.channel_type !== 'Group') { - const server = useServer(channel.server); - const a = server?.roles ?? {}; - for (let b of Object.keys(a)) { - roles[b] = { - name: a[b].name, - permissions: a[b].permissions[1] - }; - } - } + const keys = ["default", ...Object.keys(roles)]; - const keys = [ 'default', ...Object.keys(roles) ]; + const defaultRole = { + name: "Default", + permissions: + (channel.channel_type === "Group" + ? channel.permissions + : channel.default_permissions) ?? DEFAULT_PERMISSION_DM, + }; + const selectedRole = selected === "default" ? defaultRole : roles[selected]; - const defaultRole = { name: 'Default', permissions: (channel.channel_type === 'Group' ? channel.permissions : channel.default_permissions) ?? DEFAULT_PERMISSION_DM }; - const selectedRole = selected === 'default' ? defaultRole : roles[selected]; + if (!selectedRole) { + useEffect(() => setSelected("default"), []); + return null; + } - if (!selectedRole) { - useEffect(() => setSelected('default'), [ ]); - return null; - } + const [p, setPerm] = useState(selectedRole.permissions >>> 0); - const [ p, setPerm ] = useState(selectedRole.permissions >>> 0); + useEffect(() => { + setPerm(selectedRole.permissions >>> 0); + }, [selected, selectedRole.permissions]); - useEffect(() => { - setPerm(selectedRole.permissions >>> 0); - }, [ selected, selectedRole.permissions ]); - - return ( - <div> - <Tip warning>This section is under construction.</Tip> - <h2>select role</h2> - { selected } - { keys - .map(id => { - let role: R = id === 'default' ? defaultRole : roles[id]; + return ( + <div> + <Tip warning>This section is under construction.</Tip> + <h2>select role</h2> + {selected} + {keys.map((id) => { + let role: R = id === "default" ? defaultRole : roles[id]; - return ( - <Checkbox checked={selected === id} onChange={selected => selected && setSelected(id)}> - { role.name } - </Checkbox> - ) - }) - } - <h2>channel per??issions</h2> - { Object.keys(ChannelPermission) - .map(perm => { - let value = ChannelPermission[perm as keyof typeof ChannelPermission]; - if (value & DEFAULT_PERMISSION_DM) { - return ( - <Checkbox checked={(p & value) > 0} onChange={c => setPerm(c ? (p | value) : (p ^ value))}> - { perm } - </Checkbox> - ) - } - }) - } - <Button contrast onClick={() => { - client.channels.setPermissions(channel._id, selected, p); - }}>click here to save permissions for role</Button> - </div> - ); + return ( + <Checkbox + checked={selected === id} + onChange={(selected) => selected && setSelected(id)}> + {role.name} + </Checkbox> + ); + })} + <h2>channel per??issions</h2> + {Object.keys(ChannelPermission).map((perm) => { + let value = + ChannelPermission[perm as keyof typeof ChannelPermission]; + if (value & DEFAULT_PERMISSION_DM) { + return ( + <Checkbox + checked={(p & value) > 0} + onChange={(c) => + setPerm(c ? p | value : p ^ value) + }> + {perm} + </Checkbox> + ); + } + })} + <Button + contrast + onClick={() => { + client.channels.setPermissions(channel._id, selected, p); + }}> + click here to save permissions for role + </Button> + </div> + ); } diff --git a/src/pages/settings/panes/Account.tsx b/src/pages/settings/panes/Account.tsx index 88a7839c70b7e53a71451de484fb3a5624ed129d..08beb2e71b4bed0079d11607b4ae717e5b4ad548 100644 --- a/src/pages/settings/panes/Account.tsx +++ b/src/pages/settings/panes/Account.tsx @@ -1,94 +1,106 @@ -import { Text } from "preact-i18n"; -import styles from "./Panes.module.scss"; -import Tip from "../../../components/ui/Tip"; -import Button from "../../../components/ui/Button"; -import { Users } from "revolt.js/dist/api/objects"; -import { Link, useHistory } from "react-router-dom"; -import Overline from "../../../components/ui/Overline"; -import { Envelope, Key } from "@styled-icons/boxicons-solid"; import { At } from "@styled-icons/boxicons-regular"; +import { Envelope, Key } from "@styled-icons/boxicons-solid"; +import { Link, useHistory } from "react-router-dom"; +import { Users } from "revolt.js/dist/api/objects"; + +import styles from "./Panes.module.scss"; +import { Text } from "preact-i18n"; import { useContext, useEffect, useState } from "preact/hooks"; -import UserIcon from "../../../components/common/user/UserIcon"; -import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks"; + import { useIntermediate } from "../../../context/intermediate/Intermediate"; -import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; +import { + ClientStatus, + StatusContext, +} from "../../../context/revoltjs/RevoltClient"; +import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks"; + +import UserIcon from "../../../components/common/user/UserIcon"; +import Button from "../../../components/ui/Button"; +import Overline from "../../../components/ui/Overline"; +import Tip from "../../../components/ui/Tip"; export function Account() { - const { openScreen } = useIntermediate(); - const status = useContext(StatusContext); + const { openScreen } = useIntermediate(); + const status = useContext(StatusContext); - const ctx = useForceUpdate(); - const user = useSelf(ctx); - if (!user) return null; + const ctx = useForceUpdate(); + const user = useSelf(ctx); + if (!user) return null; - const [email, setEmail] = useState("..."); - const [profile, setProfile] = useState<undefined | Users.Profile>( - undefined - ); - const history = useHistory(); + const [email, setEmail] = useState("..."); + const [profile, setProfile] = useState<undefined | Users.Profile>( + undefined, + ); + const history = useHistory(); - function switchPage(to: string) { - history.replace(`/settings/${to}`); - } + function switchPage(to: string) { + history.replace(`/settings/${to}`); + } - useEffect(() => { - if (email === "..." && status === ClientStatus.ONLINE) { - ctx.client - .req("GET", "/auth/user") - .then(account => setEmail(account.email)); - } + useEffect(() => { + if (email === "..." && status === ClientStatus.ONLINE) { + ctx.client + .req("GET", "/auth/user") + .then((account) => setEmail(account.email)); + } - if (profile === undefined && status === ClientStatus.ONLINE) { - ctx.client.users - .fetchProfile(user._id) - .then(profile => setProfile(profile ?? {})); - } - }, [status]); + if (profile === undefined && status === ClientStatus.ONLINE) { + ctx.client.users + .fetchProfile(user._id) + .then((profile) => setProfile(profile ?? {})); + } + }, [status]); - return ( - <div className={styles.user}> - <div className={styles.banner}> - <UserIcon className={styles.avatar} target={user} size={72} onClick={() => switchPage("profile")}/> - <div className={styles.username}>@{user.username}</div> - </div> - <div className={styles.details}> - {([ - ["username", user.username, <At size={24} />], - ["email", email, <Envelope size={24} />], - ["password", "*****", <Key size={24} />] - ] as const).map(([field, value, icon]) => ( - <div> - {icon} - <div className={styles.detail}> - <Overline> - <Text id={`login.${field}`} /> - </Overline> - <p>{value}</p> - </div> - <div> - <Button - onClick={() => - openScreen({ - id: "modify_account", - field: field - }) - } - contrast - > - <Text id="app.settings.pages.account.change_field" /> - </Button> - </div> - </div> - ))} - </div> - <Tip> - <span> - <Text id="app.settings.tips.account.a" /> - </span>{" "} - <a onClick={() => switchPage("profile")}> - <Text id="app.settings.tips.account.b" /> - </a> - </Tip> - </div> - ); + return ( + <div className={styles.user}> + <div className={styles.banner}> + <UserIcon + className={styles.avatar} + target={user} + size={72} + onClick={() => switchPage("profile")} + /> + <div className={styles.username}>@{user.username}</div> + </div> + <div className={styles.details}> + {( + [ + ["username", user.username, <At size={24} />], + ["email", email, <Envelope size={24} />], + ["password", "*****", <Key size={24} />], + ] as const + ).map(([field, value, icon]) => ( + <div> + {icon} + <div className={styles.detail}> + <Overline> + <Text id={`login.${field}`} /> + </Overline> + <p>{value}</p> + </div> + <div> + <Button + onClick={() => + openScreen({ + id: "modify_account", + field: field, + }) + } + contrast> + <Text id="app.settings.pages.account.change_field" /> + </Button> + </div> + </div> + ))} + </div> + <Tip> + <span> + <Text id="app.settings.tips.account.a" /> + </span>{" "} + <a onClick={() => switchPage("profile")}> + <Text id="app.settings.tips.account.b" /> + </a> + </Tip> + </div> + ); } diff --git a/src/pages/settings/panes/Appearance.tsx b/src/pages/settings/panes/Appearance.tsx index 714e4086ec8a7bb04743dd6d2245236d5ee2886a..337e7451b426aa10244f12e069b0d9efff4a0925 100644 --- a/src/pages/settings/panes/Appearance.tsx +++ b/src/pages/settings/panes/Appearance.tsx @@ -1,116 +1,130 @@ -import { Text } from "preact-i18n"; +// @ts-ignore +import pSBC from "shade-blend-color"; + import styles from "./Panes.module.scss"; +import { Text } from "preact-i18n"; +import { useCallback, useContext, useEffect, useState } from "preact/hooks"; + +import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import { debounce } from "../../../lib/debounce"; -import Button from "../../../components/ui/Button"; -import Checkbox from "../../../components/ui/Checkbox"; -import ComboBox from "../../../components/ui/ComboBox"; -import InputBox from "../../../components/ui/InputBox"; + +import { dispatch } from "../../../redux"; import { connectState } from "../../../redux/connector"; -import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; -import ColourSwatches from "../../../components/ui/ColourSwatches"; import { EmojiPacks, Settings } from "../../../redux/reducers/settings"; -import { useCallback, useContext, useEffect, useState } from "preact/hooks"; -import { useIntermediate } from "../../../context/intermediate/Intermediate"; -import CollapsibleSection from "../../../components/common/CollapsibleSection"; -import { DEFAULT_FONT, DEFAULT_MONO_FONT, FONTS, FONT_KEYS, MONOSCAPE_FONTS, MONOSCAPE_FONT_KEYS, Theme, ThemeContext, ThemeOptions } from "../../../context/Theme"; - -// @ts-ignore -import pSBC from 'shade-blend-color'; -import lightSVG from '../assets/light.svg'; -import darkSVG from '../assets/dark.svg'; +import { + DEFAULT_FONT, + DEFAULT_MONO_FONT, + FONTS, + FONT_KEYS, + MONOSCAPE_FONTS, + MONOSCAPE_FONT_KEYS, + Theme, + ThemeContext, + ThemeOptions, +} from "../../../context/Theme"; +import { useIntermediate } from "../../../context/intermediate/Intermediate"; -import mutantSVG from '../assets/mutant_emoji.svg'; -import notoSVG from '../assets/noto_emoji.svg'; -import openmojiSVG from '../assets/openmoji_emoji.svg'; -import twemojiSVG from '../assets/twemoji_emoji.svg'; -import { dispatch } from "../../../redux"; +import CollapsibleSection from "../../../components/common/CollapsibleSection"; +import Button from "../../../components/ui/Button"; +import Checkbox from "../../../components/ui/Checkbox"; +import ColourSwatches from "../../../components/ui/ColourSwatches"; +import ComboBox from "../../../components/ui/ComboBox"; +import InputBox from "../../../components/ui/InputBox"; +import darkSVG from "../assets/dark.svg"; +import lightSVG from "../assets/light.svg"; +import mutantSVG from "../assets/mutant_emoji.svg"; +import notoSVG from "../assets/noto_emoji.svg"; +import openmojiSVG from "../assets/openmoji_emoji.svg"; +import twemojiSVG from "../assets/twemoji_emoji.svg"; interface Props { - settings: Settings; + settings: Settings; } // ! FIXME: code needs to be rewritten to fix jittering export function Component(props: Props) { - const theme = useContext(ThemeContext); - const { writeClipboard, openScreen } = useIntermediate(); + const theme = useContext(ThemeContext); + const { writeClipboard, openScreen } = useIntermediate(); - function setTheme(theme: ThemeOptions) { - dispatch({ - type: "SETTINGS_SET_THEME", - theme - }); - } + function setTheme(theme: ThemeOptions) { + dispatch({ + type: "SETTINGS_SET_THEME", + theme, + }); + } - function pushOverride(custom: Partial<Theme>) { - dispatch({ - type: "SETTINGS_SET_THEME_OVERRIDE", - custom - }); - } + function pushOverride(custom: Partial<Theme>) { + dispatch({ + type: "SETTINGS_SET_THEME_OVERRIDE", + custom, + }); + } - function setAccent(accent: string) { - setOverride({ - accent, - "scrollbar-thumb": pSBC(-0.2, accent) - }); - } + function setAccent(accent: string) { + setOverride({ + accent, + "scrollbar-thumb": pSBC(-0.2, accent), + }); + } - const emojiPack = props.settings.appearance?.emojiPack ?? 'mutant'; - function setEmojiPack(emojiPack: EmojiPacks) { - dispatch({ - type: 'SETTINGS_SET_APPEARANCE', - options: { - emojiPack - } - }); - } + const emojiPack = props.settings.appearance?.emojiPack ?? "mutant"; + function setEmojiPack(emojiPack: EmojiPacks) { + dispatch({ + type: "SETTINGS_SET_APPEARANCE", + options: { + emojiPack, + }, + }); + } - const setOverride = useCallback(debounce(pushOverride, 200), []) as ( - custom: Partial<Theme> - ) => void; - const [ css, setCSS ] = useState(props.settings.theme?.custom?.css ?? ''); + const setOverride = useCallback(debounce(pushOverride, 200), []) as ( + custom: Partial<Theme>, + ) => void; + const [css, setCSS] = useState(props.settings.theme?.custom?.css ?? ""); - useEffect(() => setOverride({ css }), [ css ]); + useEffect(() => setOverride({ css }), [css]); - const selected = props.settings.theme?.preset ?? "dark"; - return ( - <div className={styles.appearance}> - <h3> - <Text id="app.settings.pages.appearance.theme" /> - </h3> - <div className={styles.themes}> - <div className={styles.theme}> - <img - src={lightSVG} - data-active={selected === "light"} - onClick={() => - selected !== "light" && - setTheme({ preset: "light" }) - } /> - <h4> - <Text id="app.settings.pages.appearance.color.light" /> - </h4> - </div> - <div className={styles.theme}> - <img - src={darkSVG} - data-active={selected === "dark"} - onClick={() => - selected !== "dark" && setTheme({ preset: "dark" }) - } /> - <h4> - <Text id="app.settings.pages.appearance.color.dark" /> - </h4> - </div> - </div> + const selected = props.settings.theme?.preset ?? "dark"; + return ( + <div className={styles.appearance}> + <h3> + <Text id="app.settings.pages.appearance.theme" /> + </h3> + <div className={styles.themes}> + <div className={styles.theme}> + <img + src={lightSVG} + data-active={selected === "light"} + onClick={() => + selected !== "light" && + setTheme({ preset: "light" }) + } + /> + <h4> + <Text id="app.settings.pages.appearance.color.light" /> + </h4> + </div> + <div className={styles.theme}> + <img + src={darkSVG} + data-active={selected === "dark"} + onClick={() => + selected !== "dark" && setTheme({ preset: "dark" }) + } + /> + <h4> + <Text id="app.settings.pages.appearance.color.dark" /> + </h4> + </div> + </div> - <h3> - <Text id="app.settings.pages.appearance.accent_selector" /> - </h3> - <ColourSwatches value={theme.accent} onChange={setAccent} /> + <h3> + <Text id="app.settings.pages.appearance.accent_selector" /> + </h3> + <ColourSwatches value={theme.accent} onChange={setAccent} /> - {/*<h3> + {/*<h3> <Text id="app.settings.pages.appearance.message_display" /> </h3> <div className={styles.display}> @@ -132,186 +146,229 @@ export function Component(props: Props) { </Radio> </div>*/} - <h3> - <Text id="app.settings.pages.appearance.font" /> - </h3> - <ComboBox value={theme.font ?? DEFAULT_FONT} onChange={e => setTheme({ custom: { font: e.currentTarget.value as any } })}> - { - FONT_KEYS - .map(key => - <option value={key}>{ FONTS[key as keyof typeof FONTS].name }</option> - ) - } - </ComboBox> - <p> - <Checkbox checked={props.settings.theme?.ligatures === true} - onChange={() => setTheme({ ligatures: !props.settings.theme?.ligatures })} - description={<Text id="app.settings.pages.appearance.ligatures_desc" />}> - <Text id="app.settings.pages.appearance.ligatures" /> - </Checkbox> - </p> + <h3> + <Text id="app.settings.pages.appearance.font" /> + </h3> + <ComboBox + value={theme.font ?? DEFAULT_FONT} + onChange={(e) => + setTheme({ custom: { font: e.currentTarget.value as any } }) + }> + {FONT_KEYS.map((key) => ( + <option value={key}> + {FONTS[key as keyof typeof FONTS].name} + </option> + ))} + </ComboBox> + <p> + <Checkbox + checked={props.settings.theme?.ligatures === true} + onChange={() => + setTheme({ + ligatures: !props.settings.theme?.ligatures, + }) + } + description={ + <Text id="app.settings.pages.appearance.ligatures_desc" /> + }> + <Text id="app.settings.pages.appearance.ligatures" /> + </Checkbox> + </p> - <h3> - <Text id="app.settings.pages.appearance.emoji_pack" /> - </h3> - <div className={styles.emojiPack}> - <div className={styles.row}> - <div> - <div className={styles.button} - onClick={() => setEmojiPack('mutant')} - data-active={emojiPack === 'mutant'}> - <img src={mutantSVG} draggable={false} /> - </div> - <h4>Mutant Remix <a href="https://mutant.revolt.chat" target="_blank">(by Revolt)</a></h4> - </div> - <div> - <div className={styles.button} - onClick={() => setEmojiPack('twemoji')} - data-active={emojiPack === 'twemoji'}> - <img src={twemojiSVG} draggable={false} /> - </div> - <h4>Twemoji</h4> - </div> - </div> - <div className={styles.row}> - <div> - <div className={styles.button} - onClick={() => setEmojiPack('openmoji')} - data-active={emojiPack === 'openmoji'}> - <img src={openmojiSVG} draggable={false} /> - </div> - <h4>Openmoji</h4> - </div> - <div> - <div className={styles.button} - onClick={() => setEmojiPack('noto')} - data-active={emojiPack === 'noto'}> - <img src={notoSVG} draggable={false} /> - </div> - <h4>Noto Emoji</h4> - </div> - </div> - </div> + <h3> + <Text id="app.settings.pages.appearance.emoji_pack" /> + </h3> + <div className={styles.emojiPack}> + <div className={styles.row}> + <div> + <div + className={styles.button} + onClick={() => setEmojiPack("mutant")} + data-active={emojiPack === "mutant"}> + <img src={mutantSVG} draggable={false} /> + </div> + <h4> + Mutant Remix{" "} + <a + href="https://mutant.revolt.chat" + target="_blank"> + (by Revolt) + </a> + </h4> + </div> + <div> + <div + className={styles.button} + onClick={() => setEmojiPack("twemoji")} + data-active={emojiPack === "twemoji"}> + <img src={twemojiSVG} draggable={false} /> + </div> + <h4>Twemoji</h4> + </div> + </div> + <div className={styles.row}> + <div> + <div + className={styles.button} + onClick={() => setEmojiPack("openmoji")} + data-active={emojiPack === "openmoji"}> + <img src={openmojiSVG} draggable={false} /> + </div> + <h4>Openmoji</h4> + </div> + <div> + <div + className={styles.button} + onClick={() => setEmojiPack("noto")} + data-active={emojiPack === "noto"}> + <img src={notoSVG} draggable={false} /> + </div> + <h4>Noto Emoji</h4> + </div> + </div> + </div> - <CollapsibleSection id="settings_advanced_appearance" defaultValue={false} summary={<Text id="app.settings.pages.appearance.advanced" />}> - <h3> - <Text id="app.settings.pages.appearance.overrides" /> - </h3> - <div className={styles.actions}> - <Button contrast - onClick={() => setTheme({ custom: {} })}> - <Text id="app.settings.pages.appearance.reset_overrides" /> - </Button> - <Button contrast - onClick={() => writeClipboard(JSON.stringify(theme))}> - <Text id="app.settings.pages.appearance.export_clipboard" /> - </Button> - <Button contrast - onClick={async () => { - const text = await navigator.clipboard.readText(); - setOverride(JSON.parse(text)); - }}> - <Text id="app.settings.pages.appearance.import_clipboard" /> - </Button> - <Button contrast - onClick={async () => { - openScreen({ - id: "_input", - question: <Text id="app.settings.pages.appearance.import_theme" />, - field: <Text id="app.settings.pages.appearance.theme_data" />, - callback: async string => setOverride(JSON.parse(string)) - }); - }}> - <Text id="app.settings.pages.appearance.import_manual" /> - </Button> - </div> - <div className={styles.overrides}> - {([ - "accent", - "background", - "foreground", - "primary-background", - "primary-header", - "secondary-background", - "secondary-foreground", - "secondary-header", - "tertiary-background", - "tertiary-foreground", - "block", - "message-box", - "mention", - "scrollbar-thumb", - "scrollbar-track", - "status-online", - "status-away", - "status-busy", - "status-streaming", - "status-invisible", - "success", - "warning", - "error", - "hover" - ] as const).map(x => ( - <div className={styles.entry} key={x}> - <span>{x}</span> - <div className={styles.override}> - <div className={styles.picker} - style={{ backgroundColor: theme[x] }}> - <input - type="color" - value={theme[x]} - onChange={v => - setOverride({ - [x]: v.currentTarget.value - }) - } - /> - </div> - <InputBox - className={styles.text} - value={theme[x]} - onChange={y => - setOverride({ - [x]: y.currentTarget.value - }) - } - /> - </div> - </div> - ))} - </div> + <CollapsibleSection + id="settings_advanced_appearance" + defaultValue={false} + summary={<Text id="app.settings.pages.appearance.advanced" />}> + <h3> + <Text id="app.settings.pages.appearance.overrides" /> + </h3> + <div className={styles.actions}> + <Button contrast onClick={() => setTheme({ custom: {} })}> + <Text id="app.settings.pages.appearance.reset_overrides" /> + </Button> + <Button + contrast + onClick={() => writeClipboard(JSON.stringify(theme))}> + <Text id="app.settings.pages.appearance.export_clipboard" /> + </Button> + <Button + contrast + onClick={async () => { + const text = await navigator.clipboard.readText(); + setOverride(JSON.parse(text)); + }}> + <Text id="app.settings.pages.appearance.import_clipboard" /> + </Button> + <Button + contrast + onClick={async () => { + openScreen({ + id: "_input", + question: ( + <Text id="app.settings.pages.appearance.import_theme" /> + ), + field: ( + <Text id="app.settings.pages.appearance.theme_data" /> + ), + callback: async (string) => + setOverride(JSON.parse(string)), + }); + }}> + <Text id="app.settings.pages.appearance.import_manual" /> + </Button> + </div> + <div className={styles.overrides}> + {( + [ + "accent", + "background", + "foreground", + "primary-background", + "primary-header", + "secondary-background", + "secondary-foreground", + "secondary-header", + "tertiary-background", + "tertiary-foreground", + "block", + "message-box", + "mention", + "scrollbar-thumb", + "scrollbar-track", + "status-online", + "status-away", + "status-busy", + "status-streaming", + "status-invisible", + "success", + "warning", + "error", + "hover", + ] as const + ).map((x) => ( + <div className={styles.entry} key={x}> + <span>{x}</span> + <div className={styles.override}> + <div + className={styles.picker} + style={{ backgroundColor: theme[x] }}> + <input + type="color" + value={theme[x]} + onChange={(v) => + setOverride({ + [x]: v.currentTarget.value, + }) + } + /> + </div> + <InputBox + className={styles.text} + value={theme[x]} + onChange={(y) => + setOverride({ + [x]: y.currentTarget.value, + }) + } + /> + </div> + </div> + ))} + </div> - <h3> - <Text id="app.settings.pages.appearance.mono_font" /> - </h3> - <ComboBox value={theme.monoscapeFont ?? DEFAULT_MONO_FONT} onChange={e => setTheme({ custom: { monoscapeFont: e.currentTarget.value as any } })}> - { - MONOSCAPE_FONT_KEYS - .map(key => - <option value={key}>{ MONOSCAPE_FONTS[key as keyof typeof MONOSCAPE_FONTS].name }</option> - ) - } - </ComboBox> + <h3> + <Text id="app.settings.pages.appearance.mono_font" /> + </h3> + <ComboBox + value={theme.monoscapeFont ?? DEFAULT_MONO_FONT} + onChange={(e) => + setTheme({ + custom: { + monoscapeFont: e.currentTarget.value as any, + }, + }) + }> + {MONOSCAPE_FONT_KEYS.map((key) => ( + <option value={key}> + { + MONOSCAPE_FONTS[ + key as keyof typeof MONOSCAPE_FONTS + ].name + } + </option> + ))} + </ComboBox> - <h3> - <Text id="app.settings.pages.appearance.custom_css" /> - </h3> - <TextAreaAutoSize - maxRows={20} - minHeight={480} - code - value={css} - onChange={ev => setCSS(ev.currentTarget.value)} /> - </CollapsibleSection> - </div> - ); + <h3> + <Text id="app.settings.pages.appearance.custom_css" /> + </h3> + <TextAreaAutoSize + maxRows={20} + minHeight={480} + code + value={css} + onChange={(ev) => setCSS(ev.currentTarget.value)} + /> + </CollapsibleSection> + </div> + ); } -export const Appearance = connectState( - Component, - state => { - return { - settings: state.settings - }; - } -); +export const Appearance = connectState(Component, (state) => { + return { + settings: state.settings, + }; +}); diff --git a/src/pages/settings/panes/Experiments.tsx b/src/pages/settings/panes/Experiments.tsx index c5dee4a9e303f48eda9334bd10aba88cfd3c6f0e..1ecf8c8e4222d10353523b9e097443ee71feecdb 100644 --- a/src/pages/settings/panes/Experiments.tsx +++ b/src/pages/settings/panes/Experiments.tsx @@ -1,55 +1,55 @@ -import { Text } from "preact-i18n"; import styles from "./Panes.module.scss"; +import { Text } from "preact-i18n"; + import { dispatch } from "../../../redux"; -import Checkbox from "../../../components/ui/Checkbox"; import { connectState } from "../../../redux/connector"; -import { AVAILABLE_EXPERIMENTS, ExperimentOptions } from "../../../redux/reducers/experiments"; +import { + AVAILABLE_EXPERIMENTS, + ExperimentOptions, +} from "../../../redux/reducers/experiments"; + +import Checkbox from "../../../components/ui/Checkbox"; interface Props { - options?: ExperimentOptions; + options?: ExperimentOptions; } export function Component(props: Props) { - return ( - <div className={styles.experiments}> - <h3> - <Text id="app.settings.pages.experiments.features" /> - </h3> - { - (AVAILABLE_EXPERIMENTS).map( - key => - <Checkbox - checked={(props.options?.enabled ?? []).indexOf(key) > -1} - onChange={enabled => - dispatch({ - type: enabled ? 'EXPERIMENTS_ENABLE' : 'EXPERIMENTS_DISABLE', - key - }) - } - > - <Text id={`app.settings.pages.experiments.titles.${key}`} /> - <p> - <Text id={`app.settings.pages.experiments.descriptions.${key}`} /> - </p> - </Checkbox> - ) - } - { - AVAILABLE_EXPERIMENTS.length === 0 && - <div className={styles.empty}> - <Text id="app.settings.pages.experiments.not_available" /> - </div> - - } - </div> - ); + return ( + <div className={styles.experiments}> + <h3> + <Text id="app.settings.pages.experiments.features" /> + </h3> + {AVAILABLE_EXPERIMENTS.map((key) => ( + <Checkbox + checked={(props.options?.enabled ?? []).indexOf(key) > -1} + onChange={(enabled) => + dispatch({ + type: enabled + ? "EXPERIMENTS_ENABLE" + : "EXPERIMENTS_DISABLE", + key, + }) + }> + <Text id={`app.settings.pages.experiments.titles.${key}`} /> + <p> + <Text + id={`app.settings.pages.experiments.descriptions.${key}`} + /> + </p> + </Checkbox> + ))} + {AVAILABLE_EXPERIMENTS.length === 0 && ( + <div className={styles.empty}> + <Text id="app.settings.pages.experiments.not_available" /> + </div> + )} + </div> + ); } -export const ExperimentsPage = connectState( - Component, - state => { - return { - options: state.experiments - }; - } -); +export const ExperimentsPage = connectState(Component, (state) => { + return { + options: state.experiments, + }; +}); diff --git a/src/pages/settings/panes/Feedback.tsx b/src/pages/settings/panes/Feedback.tsx index 66d21cd692056cb77dde2a911bb783de22b7159d..fe86dd0c423d80cb42f7f26ee67391d0ce5933b6 100644 --- a/src/pages/settings/panes/Feedback.tsx +++ b/src/pages/settings/panes/Feedback.tsx @@ -1,106 +1,110 @@ -import { useState } from "preact/hooks"; import styles from "./Panes.module.scss"; import { Localizer, Text } from "preact-i18n"; -import Radio from "../../../components/ui/Radio"; +import { useState } from "preact/hooks"; + +import { useSelf } from "../../../context/revoltjs/hooks"; + import Button from "../../../components/ui/Button"; import InputBox from "../../../components/ui/InputBox"; +import Radio from "../../../components/ui/Radio"; import TextArea from "../../../components/ui/TextArea"; -import { useSelf } from "../../../context/revoltjs/hooks"; export function Feedback() { - const user = useSelf(); - const [other, setOther] = useState(""); - const [description, setDescription] = useState(""); - const [state, setState] = useState<"ready" | "sending" | "sent">("ready"); - const [checked, setChecked] = useState< - "Bug" | "Feature Request" | "__other_option__" - >("Bug"); + const user = useSelf(); + const [other, setOther] = useState(""); + const [description, setDescription] = useState(""); + const [state, setState] = useState<"ready" | "sending" | "sent">("ready"); + const [checked, setChecked] = useState< + "Bug" | "Feature Request" | "__other_option__" + >("Bug"); - async function onSubmit(ev: JSX.TargetedEvent<HTMLFormElement, Event>) { - ev.preventDefault(); - setState("sending"); + async function onSubmit(ev: JSX.TargetedEvent<HTMLFormElement, Event>) { + ev.preventDefault(); + setState("sending"); - await fetch( - `https://workers.revolt.chat/feedback`, - { - method: "POST", - body: JSON.stringify({ - checked, - other, - description, - name: user?.username ?? "Unknown User" - }), - mode: 'no-cors' - } - ); + await fetch(`https://workers.revolt.chat/feedback`, { + method: "POST", + body: JSON.stringify({ + checked, + other, + description, + name: user?.username ?? "Unknown User", + }), + mode: "no-cors", + }); - setState("sent"); - setChecked("Bug"); - setDescription(""); - setOther(""); - } + setState("sent"); + setChecked("Bug"); + setDescription(""); + setOther(""); + } - return ( - <form className={styles.feedback} onSubmit={onSubmit}> - <h3> - <Text id="app.settings.pages.feedback.report" /> - </h3> - <div className={styles.options}> - <Radio - checked={checked === "Bug"} - disabled={state === "sending"} - onSelect={() => setChecked("Bug")}> - <Text id="app.settings.pages.feedback.bug" /> - </Radio> - <Radio - disabled={state === "sending"} - checked={checked === "Feature Request"} - onSelect={() => setChecked("Feature Request")}> - <Text id="app.settings.pages.feedback.feature" /> - </Radio> - { (location.hostname === 'vite.revolt.chat' || location.hostname === 'local.revolt.chat') && <Radio - disabled={state === "sending"} - checked={other === "Revite"} - onSelect={() => { - setChecked("__other_option__"); - setOther("Revite"); - }}> - Issues with Revite - </Radio> } - <Radio - disabled={state === "sending"} - checked={checked === "__other_option__" && other !== "Revite"} - onSelect={() => setChecked("__other_option__")}> - <Localizer> - <InputBox - value={other} - disabled={state === "sending"} - name="entry.1151440373.other_option_response" - onChange={e => setOther(e.currentTarget.value)} - placeholder={ - ( - <Text id="app.settings.pages.feedback.other" /> - ) as any - } - /> - </Localizer> - </Radio> - </div> - <h3> - <Text id="app.settings.pages.feedback.describe" /> - </h3> - <TextArea - // maxRows={10} - value={description} - id="entry.685672624" - disabled={state === "sending"} - onChange={ev => setDescription(ev.currentTarget.value)} - /> - <p> - <Button type="submit" contrast> - <Text id="app.settings.pages.feedback.send" /> - </Button> - </p> - </form> - ); + return ( + <form className={styles.feedback} onSubmit={onSubmit}> + <h3> + <Text id="app.settings.pages.feedback.report" /> + </h3> + <div className={styles.options}> + <Radio + checked={checked === "Bug"} + disabled={state === "sending"} + onSelect={() => setChecked("Bug")}> + <Text id="app.settings.pages.feedback.bug" /> + </Radio> + <Radio + disabled={state === "sending"} + checked={checked === "Feature Request"} + onSelect={() => setChecked("Feature Request")}> + <Text id="app.settings.pages.feedback.feature" /> + </Radio> + {(location.hostname === "vite.revolt.chat" || + location.hostname === "local.revolt.chat") && ( + <Radio + disabled={state === "sending"} + checked={other === "Revite"} + onSelect={() => { + setChecked("__other_option__"); + setOther("Revite"); + }}> + Issues with Revite + </Radio> + )} + <Radio + disabled={state === "sending"} + checked={ + checked === "__other_option__" && other !== "Revite" + } + onSelect={() => setChecked("__other_option__")}> + <Localizer> + <InputBox + value={other} + disabled={state === "sending"} + name="entry.1151440373.other_option_response" + onChange={(e) => setOther(e.currentTarget.value)} + placeholder={ + ( + <Text id="app.settings.pages.feedback.other" /> + ) as any + } + /> + </Localizer> + </Radio> + </div> + <h3> + <Text id="app.settings.pages.feedback.describe" /> + </h3> + <TextArea + // maxRows={10} + value={description} + id="entry.685672624" + disabled={state === "sending"} + onChange={(ev) => setDescription(ev.currentTarget.value)} + /> + <p> + <Button type="submit" contrast> + <Text id="app.settings.pages.feedback.send" /> + </Button> + </p> + </form> + ); } diff --git a/src/pages/settings/panes/Languages.tsx b/src/pages/settings/panes/Languages.tsx index 077391a592367002bf611c480acb16ba71b08369..772795c6b7db4c3cc36335902850fb7f3da27be4 100644 --- a/src/pages/settings/panes/Languages.tsx +++ b/src/pages/settings/panes/Languages.tsx @@ -1,84 +1,91 @@ -import { Text } from "preact-i18n"; import styles from "./Panes.module.scss"; +import { Text } from "preact-i18n"; + import { dispatch } from "../../../redux"; -import Tip from "../../../components/ui/Tip"; +import { connectState } from "../../../redux/connector"; + +import { + Language, + LanguageEntry, + Languages as Langs, +} from "../../../context/Locale"; + import Emoji from "../../../components/common/Emoji"; import Checkbox from "../../../components/ui/Checkbox"; -import { connectState } from "../../../redux/connector"; -import { Language, LanguageEntry, Languages as Langs } from "../../../context/Locale"; +import Tip from "../../../components/ui/Tip"; type Props = { - locale: Language; -} + locale: Language; +}; -type Key = [ string, LanguageEntry ]; +type Key = [string, LanguageEntry]; -function Entry({ entry: [ x, lang ], locale }: { entry: Key } & Props) { - return ( - <Checkbox - key={x} - className={styles.entry} - checked={locale === x} - onChange={v => { - if (v) { - dispatch({ - type: "SET_LOCALE", - locale: x as Language - }); - } - }} - > - <div className={styles.flag}><Emoji size={42} emoji={lang.emoji} /></div> - <span className={styles.description}> - {lang.display} - </span> - </Checkbox> - ); +function Entry({ entry: [x, lang], locale }: { entry: Key } & Props) { + return ( + <Checkbox + key={x} + className={styles.entry} + checked={locale === x} + onChange={(v) => { + if (v) { + dispatch({ + type: "SET_LOCALE", + locale: x as Language, + }); + } + }}> + <div className={styles.flag}> + <Emoji size={42} emoji={lang.emoji} /> + </div> + <span className={styles.description}>{lang.display}</span> + </Checkbox> + ); } export function Component(props: Props) { - const languages = Object - .keys(Langs) - .map(x => [ x, Langs[x as keyof typeof Langs] ]) as Key[]; + const languages = Object.keys(Langs).map((x) => [ + x, + Langs[x as keyof typeof Langs], + ]) as Key[]; - return ( - <div className={styles.languages}> - <h3> - <Text id="app.settings.pages.language.select" /> - </h3> - <div className={styles.list}> - {languages - .filter(([, lang]) => !lang.alt) - .map(([x, lang]) => <Entry key={x} entry={[x, lang]} {...props} />)} - </div> - <h3> - <Text id="app.settings.pages.language.other" /> - </h3> - <div className={styles.list}> - {languages - .filter(([, lang]) => lang.alt) - .map(([x, lang]) => <Entry key={x} entry={[x, lang]} {...props} />)} - </div> - <Tip> - <span> - <Text id="app.settings.tips.languages.a" /> - </span>{" "} - <a - href="https://weblate.insrt.uk/engage/revolt/?utm_source=widget" - target="_blank" - > - <Text id="app.settings.tips.languages.b" /> - </a> - </Tip> - </div> - ); + return ( + <div className={styles.languages}> + <h3> + <Text id="app.settings.pages.language.select" /> + </h3> + <div className={styles.list}> + {languages + .filter(([, lang]) => !lang.alt) + .map(([x, lang]) => ( + <Entry key={x} entry={[x, lang]} {...props} /> + ))} + </div> + <h3> + <Text id="app.settings.pages.language.other" /> + </h3> + <div className={styles.list}> + {languages + .filter(([, lang]) => lang.alt) + .map(([x, lang]) => ( + <Entry key={x} entry={[x, lang]} {...props} /> + ))} + </div> + <Tip> + <span> + <Text id="app.settings.tips.languages.a" /> + </span>{" "} + <a + href="https://weblate.insrt.uk/engage/revolt/?utm_source=widget" + target="_blank"> + <Text id="app.settings.tips.languages.b" /> + </a> + </Tip> + </div> + ); } -export const Languages = connectState( - Component, - state => { - return { - locale: state.locale - }; - } -); +export const Languages = connectState(Component, (state) => { + return { + locale: state.locale, + }; +}); diff --git a/src/pages/settings/panes/Notifications.tsx b/src/pages/settings/panes/Notifications.tsx index 5098c85e9266c9291ea5b9c981e0ce9757c32aee..af26673815dffe4a72d33a587d1d6638fcb59bfe 100644 --- a/src/pages/settings/panes/Notifications.tsx +++ b/src/pages/settings/panes/Notifications.tsx @@ -1,135 +1,154 @@ -import { Text } from "preact-i18n"; -import styles from "./Panes.module.scss"; -import { dispatch } from "../../../redux"; import defaultsDeep from "lodash.defaultsdeep"; -import Checkbox from "../../../components/ui/Checkbox"; -import { connectState } from "../../../redux/connector"; -import { SOUNDS_ARRAY } from "../../../assets/sounds/Audio"; + +import styles from "./Panes.module.scss"; +import { Text } from "preact-i18n"; import { useContext, useEffect, useState } from "preact/hooks"; + import { urlBase64ToUint8Array } from "../../../lib/conversion"; -import { AppContext } from "../../../context/revoltjs/RevoltClient"; + +import { dispatch } from "../../../redux"; +import { connectState } from "../../../redux/connector"; +import { + DEFAULT_SOUNDS, + NotificationOptions, + SoundOptions, +} from "../../../redux/reducers/settings"; + import { useIntermediate } from "../../../context/intermediate/Intermediate"; -import { DEFAULT_SOUNDS, NotificationOptions, SoundOptions } from "../../../redux/reducers/settings"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; + +import Checkbox from "../../../components/ui/Checkbox"; + +import { SOUNDS_ARRAY } from "../../../assets/sounds/Audio"; interface Props { - options?: NotificationOptions; + options?: NotificationOptions; } export function Component({ options }: Props) { - const client = useContext(AppContext); - const { openScreen } = useIntermediate(); - const [pushEnabled, setPushEnabled] = useState<undefined | boolean>( - undefined - ); + const client = useContext(AppContext); + const { openScreen } = useIntermediate(); + const [pushEnabled, setPushEnabled] = useState<undefined | boolean>( + undefined, + ); - // Load current state of pushManager. - useEffect(() => { - navigator.serviceWorker?.getRegistration().then(async registration => { - const sub = await registration?.pushManager?.getSubscription(); - setPushEnabled(sub !== null && sub !== undefined); - }); - }, []); + // Load current state of pushManager. + useEffect(() => { + navigator.serviceWorker + ?.getRegistration() + .then(async (registration) => { + const sub = await registration?.pushManager?.getSubscription(); + setPushEnabled(sub !== null && sub !== undefined); + }); + }, []); - const enabledSounds: SoundOptions = defaultsDeep(options?.sounds ?? {}, DEFAULT_SOUNDS); - return ( - <div className={styles.notifications}> - <h3> - <Text id="app.settings.pages.notifications.push_notifications" /> - </h3> - <Checkbox - disabled={!("Notification" in window)} - checked={options?.desktopEnabled ?? false} - description={<Text id="app.settings.pages.notifications.descriptions.enable_desktop" />} - onChange={async desktopEnabled => { - if (desktopEnabled) { - let permission = await Notification.requestPermission(); - if (permission !== "granted") { - return openScreen({ - id: "error", - error: "DeniedNotification" - }); - } - } + const enabledSounds: SoundOptions = defaultsDeep( + options?.sounds ?? {}, + DEFAULT_SOUNDS, + ); + return ( + <div className={styles.notifications}> + <h3> + <Text id="app.settings.pages.notifications.push_notifications" /> + </h3> + <Checkbox + disabled={!("Notification" in window)} + checked={options?.desktopEnabled ?? false} + description={ + <Text id="app.settings.pages.notifications.descriptions.enable_desktop" /> + } + onChange={async (desktopEnabled) => { + if (desktopEnabled) { + let permission = await Notification.requestPermission(); + if (permission !== "granted") { + return openScreen({ + id: "error", + error: "DeniedNotification", + }); + } + } - dispatch({ - type: "SETTINGS_SET_NOTIFICATION_OPTIONS", - options: { desktopEnabled } - }); - }} - > - <Text id="app.settings.pages.notifications.enable_desktop" /> - </Checkbox> - <Checkbox - disabled={typeof pushEnabled === "undefined"} - checked={pushEnabled ?? false} - description={<Text id="app.settings.pages.notifications.descriptions.enable_push" />} - onChange={async pushEnabled => { - try { - const reg = await navigator.serviceWorker?.getRegistration(); - if (reg) { - if (pushEnabled) { - const sub = await reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array( - client.configuration!.vapid - ) - }); + dispatch({ + type: "SETTINGS_SET_NOTIFICATION_OPTIONS", + options: { desktopEnabled }, + }); + }}> + <Text id="app.settings.pages.notifications.enable_desktop" /> + </Checkbox> + <Checkbox + disabled={typeof pushEnabled === "undefined"} + checked={pushEnabled ?? false} + description={ + <Text id="app.settings.pages.notifications.descriptions.enable_push" /> + } + onChange={async (pushEnabled) => { + try { + const reg = + await navigator.serviceWorker?.getRegistration(); + if (reg) { + if (pushEnabled) { + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array( + client.configuration!.vapid, + ), + }); - // tell the server we just subscribed - const json = sub.toJSON(); - if (json.keys) {; - client.req("POST", "/push/subscribe", { - endpoint: sub.endpoint, - ...(json.keys as { p256dh: string, auth: string }) - }); - setPushEnabled(true); - } - } else { - const sub = await reg.pushManager.getSubscription(); - sub?.unsubscribe(); - setPushEnabled(false); + // tell the server we just subscribed + const json = sub.toJSON(); + if (json.keys) { + client.req("POST", "/push/subscribe", { + endpoint: sub.endpoint, + ...(json.keys as { + p256dh: string; + auth: string; + }), + }); + setPushEnabled(true); + } + } else { + const sub = + await reg.pushManager.getSubscription(); + sub?.unsubscribe(); + setPushEnabled(false); - client.req("POST", "/push/unsubscribe"); - } - } - } catch (err) { - console.error('Failed to enable push!', err); - } - }} - > - <Text id="app.settings.pages.notifications.enable_push" /> - </Checkbox> - <h3> - <Text id="app.settings.pages.notifications.sounds" /> - </h3> - { - SOUNDS_ARRAY.map(key => - <Checkbox - checked={enabledSounds[key] ? true : false} - onChange={enabled => - dispatch({ - type: "SETTINGS_SET_NOTIFICATION_OPTIONS", - options: { - sounds: { - ...options?.sounds, - [key]: enabled - } - } - }) - }> - <Text id={`app.settings.pages.notifications.sound.${key}`} /> - </Checkbox> - ) - } - </div> - ); + client.req("POST", "/push/unsubscribe"); + } + } + } catch (err) { + console.error("Failed to enable push!", err); + } + }}> + <Text id="app.settings.pages.notifications.enable_push" /> + </Checkbox> + <h3> + <Text id="app.settings.pages.notifications.sounds" /> + </h3> + {SOUNDS_ARRAY.map((key) => ( + <Checkbox + checked={enabledSounds[key] ? true : false} + onChange={(enabled) => + dispatch({ + type: "SETTINGS_SET_NOTIFICATION_OPTIONS", + options: { + sounds: { + ...options?.sounds, + [key]: enabled, + }, + }, + }) + }> + <Text + id={`app.settings.pages.notifications.sound.${key}`} + /> + </Checkbox> + ))} + </div> + ); } -export const Notifications = connectState( - Component, - state => { - return { - options: state.settings.notification - }; - } -); +export const Notifications = connectState(Component, (state) => { + return { + options: state.settings.notification, + }; +}); diff --git a/src/pages/settings/panes/Profile.tsx b/src/pages/settings/panes/Profile.tsx index 1b6fa429e0a61b1a9c0679033aad07dd6a367e13..6e1fd4d2394017f8e287f874e4b2ef39eaf6c3d9 100644 --- a/src/pages/settings/panes/Profile.tsx +++ b/src/pages/settings/panes/Profile.tsx @@ -1,143 +1,186 @@ -import styles from "./Panes.module.scss"; -import Button from "../../../components/ui/Button"; import { Users } from "revolt.js/dist/api/objects"; + +import styles from "./Panes.module.scss"; import { IntlContext, Text, translate } from "preact-i18n"; -import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import { useContext, useEffect, useState } from "preact/hooks"; + +import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; + +import { UserProfile } from "../../../context/intermediate/popovers/UserProfile"; import { FileUploader } from "../../../context/revoltjs/FileUploads"; +import { + ClientStatus, + StatusContext, +} from "../../../context/revoltjs/RevoltClient"; import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks"; -import { UserProfile } from "../../../context/intermediate/popovers/UserProfile"; -import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; -import AutoComplete, { useAutoComplete } from "../../../components/common/AutoComplete"; + +import AutoComplete, { + useAutoComplete, +} from "../../../components/common/AutoComplete"; +import Button from "../../../components/ui/Button"; export function Profile() { - const { intl } = useContext(IntlContext); - const status = useContext(StatusContext); + const { intl } = useContext(IntlContext); + const status = useContext(StatusContext); - const ctx = useForceUpdate(); - const user = useSelf(); - if (!user) return null; + const ctx = useForceUpdate(); + const user = useSelf(); + if (!user) return null; - const [profile, setProfile] = useState<undefined | Users.Profile>( - undefined - ); + const [profile, setProfile] = useState<undefined | Users.Profile>( + undefined, + ); - // ! FIXME: temporary solution - // ! we should just announce profile changes through WS - function refreshProfile() { - ctx.client.users - .fetchProfile(user!._id) - .then(profile => setProfile(profile ?? {})); - } + // ! FIXME: temporary solution + // ! we should just announce profile changes through WS + function refreshProfile() { + ctx.client.users + .fetchProfile(user!._id) + .then((profile) => setProfile(profile ?? {})); + } - useEffect(() => { - if (profile === undefined && status === ClientStatus.ONLINE) { - refreshProfile(); - } - }, [status]); + useEffect(() => { + if (profile === undefined && status === ClientStatus.ONLINE) { + refreshProfile(); + } + }, [status]); - const [ changed, setChanged ] = useState(false); - function setContent(content?: string) { - setProfile({ ...profile, content }) - if (!changed) setChanged(true) - } + const [changed, setChanged] = useState(false); + function setContent(content?: string) { + setProfile({ ...profile, content }); + if (!changed) setChanged(true); + } - const { onChange, onKeyUp, onKeyDown, onFocus, onBlur, ...autoCompleteProps } = - useAutoComplete(setContent, { - users: { type: 'all' } - }); + const { + onChange, + onKeyUp, + onKeyDown, + onFocus, + onBlur, + ...autoCompleteProps + } = useAutoComplete(setContent, { + users: { type: "all" }, + }); - return ( - <div className={styles.user}> - <h3> - <Text id="app.special.modals.actions.preview" /> - </h3> - <div className={styles.preview}> - <UserProfile - user_id={user._id} - dummy={true} - dummyProfile={profile} - onClose={() => {}} - /> - </div> - <div className={styles.row}> - <div className={styles.pfp}> - <h3> - <Text id="app.settings.pages.profile.profile_picture" /> - </h3> - <FileUploader - width={80} - height={80} - style="icon" - fileType="avatars" - behaviour="upload" - maxFileSize={4_000_000} - onUpload={avatar => ctx.client.users.editUser({ avatar })} - remove={() => ctx.client.users.editUser({ remove: 'Avatar' })} - defaultPreview={ctx.client.users.getAvatarURL(user._id, { max_side: 256 }, true)} - previewURL={ctx.client.users.getAvatarURL(user._id, { max_side: 256 }, true, true)} - /> - </div> - <div className={styles.background}> - <h3> - <Text id="app.settings.pages.profile.custom_background" /> - </h3> - <FileUploader - height={92} - style="banner" - behaviour="upload" - fileType="backgrounds" - maxFileSize={6_000_000} - onUpload={async background => { - await ctx.client.users.editUser({ profile: { background } }); - refreshProfile(); - }} - remove={async () => { - await ctx.client.users.editUser({ remove: 'ProfileBackground' }); - setProfile({ ...profile, background: undefined }); - }} - previewURL={profile?.background ? ctx.client.users.getBackgroundURL(profile, { width: 1000 }, true) : undefined} - /> - </div> - </div> - <h3> - <Text id="app.settings.pages.profile.info" /> - </h3> - <AutoComplete detached {...autoCompleteProps} /> - <TextAreaAutoSize - maxRows={10} - minHeight={200} - maxLength={2000} - value={profile?.content ?? ""} - disabled={typeof profile === "undefined"} - onChange={ev => { - onChange(ev); - setContent(ev.currentTarget.value) - }} - placeholder={translate( - `app.settings.pages.profile.${ - typeof profile === "undefined" - ? "fetching" - : "placeholder" - }`, - "", - (intl as any).dictionary as Record<string, unknown> - )} - onKeyUp={onKeyUp} - onKeyDown={onKeyDown} - onFocus={onFocus} - onBlur={onBlur} - /> - <p> - <Button contrast - onClick={() => { - setChanged(false); - ctx.client.users.editUser({ profile: { content: profile?.content } }) - }} - disabled={!changed}> - <Text id="app.special.modals.actions.save" /> - </Button> - </p> - </div> - ); + return ( + <div className={styles.user}> + <h3> + <Text id="app.special.modals.actions.preview" /> + </h3> + <div className={styles.preview}> + <UserProfile + user_id={user._id} + dummy={true} + dummyProfile={profile} + onClose={() => {}} + /> + </div> + <div className={styles.row}> + <div className={styles.pfp}> + <h3> + <Text id="app.settings.pages.profile.profile_picture" /> + </h3> + <FileUploader + width={80} + height={80} + style="icon" + fileType="avatars" + behaviour="upload" + maxFileSize={4_000_000} + onUpload={(avatar) => + ctx.client.users.editUser({ avatar }) + } + remove={() => + ctx.client.users.editUser({ remove: "Avatar" }) + } + defaultPreview={ctx.client.users.getAvatarURL( + user._id, + { max_side: 256 }, + true, + )} + previewURL={ctx.client.users.getAvatarURL( + user._id, + { max_side: 256 }, + true, + true, + )} + /> + </div> + <div className={styles.background}> + <h3> + <Text id="app.settings.pages.profile.custom_background" /> + </h3> + <FileUploader + height={92} + style="banner" + behaviour="upload" + fileType="backgrounds" + maxFileSize={6_000_000} + onUpload={async (background) => { + await ctx.client.users.editUser({ + profile: { background }, + }); + refreshProfile(); + }} + remove={async () => { + await ctx.client.users.editUser({ + remove: "ProfileBackground", + }); + setProfile({ ...profile, background: undefined }); + }} + previewURL={ + profile?.background + ? ctx.client.users.getBackgroundURL( + profile, + { width: 1000 }, + true, + ) + : undefined + } + /> + </div> + </div> + <h3> + <Text id="app.settings.pages.profile.info" /> + </h3> + <AutoComplete detached {...autoCompleteProps} /> + <TextAreaAutoSize + maxRows={10} + minHeight={200} + maxLength={2000} + value={profile?.content ?? ""} + disabled={typeof profile === "undefined"} + onChange={(ev) => { + onChange(ev); + setContent(ev.currentTarget.value); + }} + placeholder={translate( + `app.settings.pages.profile.${ + typeof profile === "undefined" + ? "fetching" + : "placeholder" + }`, + "", + (intl as any).dictionary as Record<string, unknown>, + )} + onKeyUp={onKeyUp} + onKeyDown={onKeyDown} + onFocus={onFocus} + onBlur={onBlur} + /> + <p> + <Button + contrast + onClick={() => { + setChanged(false); + ctx.client.users.editUser({ + profile: { content: profile?.content }, + }); + }} + disabled={!changed}> + <Text id="app.special.modals.actions.save" /> + </Button> + </p> + </div> + ); } diff --git a/src/pages/settings/panes/Sessions.tsx b/src/pages/settings/panes/Sessions.tsx index 2ec357be7e352aea5d0ad1676eaa554f2b29e7d6..38a85576f1ba68940a1cc73f2694a634641306cc 100644 --- a/src/pages/settings/panes/Sessions.tsx +++ b/src/pages/settings/panes/Sessions.tsx @@ -1,186 +1,186 @@ +import { HelpCircle } from "@styled-icons/boxicons-regular"; +import { + Android, + Firefoxbrowser, + Googlechrome, + Ios, + Linux, + Macos, + Microsoftedge, + Safari, + Windows, +} from "@styled-icons/simple-icons"; import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { useHistory } from "react-router-dom"; import { decodeTime } from "ulid"; -import { Text } from "preact-i18n"; + import styles from "./Panes.module.scss"; -import Tip from "../../../components/ui/Tip"; -import { useHistory } from "react-router-dom"; -import Button from "../../../components/ui/Button"; -import Preloader from "../../../components/ui/Preloader"; +import { Text } from "preact-i18n"; import { useContext, useEffect, useState } from "preact/hooks"; + import { AppContext } from "../../../context/revoltjs/RevoltClient"; -import { HelpCircle } from "@styled-icons/boxicons-regular"; -import { - Android, - Firefoxbrowser, - Googlechrome, - Ios, - Linux, - Macos, - Microsoftedge, - Safari, - Windows -} from "@styled-icons/simple-icons"; +import Button from "../../../components/ui/Button"; +import Preloader from "../../../components/ui/Preloader"; +import Tip from "../../../components/ui/Tip"; -import relativeTime from "dayjs/plugin/relativeTime"; dayjs.extend(relativeTime); interface Session { - id: string; - friendly_name: string; + id: string; + friendly_name: string; } export function Sessions() { - const client = useContext(AppContext); - const deviceId = client.session?.id; - - const [sessions, setSessions] = useState<Session[] | undefined>(undefined); - const [attemptingDelete, setDelete] = useState<string[]>([]); - const history = useHistory(); - - function switchPage(to: string) { - history.replace(`/settings/${to}`); - } - - useEffect(() => { - client.req("GET", "/auth/sessions").then(data => { - data.sort( - (a, b) => - (b.id === deviceId ? 1 : 0) - (a.id === deviceId ? 1 : 0) - ); - setSessions(data); - }); - }, []); - - if (typeof sessions === "undefined") { - return ( - <div className={styles.loader}> - <Preloader type="ring" /> - </div> - ); - } - - function getIcon(session: Session) { - const name = session.friendly_name; - switch (true) { - case /firefox/i.test(name): - return <Firefoxbrowser />; - case /chrome/i.test(name): - return <Googlechrome />; - case /safari/i.test(name): - return <Safari />; - case /edge/i.test(name): - return <Microsoftedge />; - default: - return <HelpCircle />; - } - } - - function getSystemIcon(session: Session) { - const name = session.friendly_name; - switch (true) { - case /linux/i.test(name): - return <Linux />; - case /android/i.test(name): - return <Android />; - case /mac.*os/i.test(name): - return <Macos />; - case /ios/i.test(name): - return <Ios />; - case /windows/i.test(name): - return <Windows />; - default: - return null; - } - } - - const mapped = sessions.map(session => { - return { - ...session, - timestamp: decodeTime(session.id) - }; - }); - - mapped.sort((a, b) => b.timestamp - a.timestamp); - let id = mapped.findIndex(x => x.id === deviceId); - - const render = [ - mapped[id], - ...mapped.slice(0, id), - ...mapped.slice(id + 1, mapped.length) - ]; - - return ( - <div className={styles.sessions}> - <h3> - <Text id="app.settings.pages.sessions.active_sessions" /> - </h3> - {render.map(session => ( - <div - className={styles.entry} - data-active={session.id === deviceId} - data-deleting={attemptingDelete.indexOf(session.id) > -1} - > - {deviceId === session.id && ( - <span className={styles.label}> - <Text id="app.settings.pages.sessions.this_device" />{" "} - </span> - )} - <div className={styles.session}> - <div className={styles.icon}> - {getIcon(session)} - <div>{getSystemIcon(session)}</div> - </div> - <div className={styles.info}> - <span className={styles.name}> - {session.friendly_name} - </span> - <span className={styles.time}> - <Text - id="app.settings.pages.sessions.created" - fields={{ - time_ago: dayjs( - session.timestamp - ).fromNow() - }} - /> - </span> - </div> - {deviceId !== session.id && ( - <Button - onClick={async () => { - setDelete([ - ...attemptingDelete, - session.id - ]); - await client.req( - "DELETE", - `/auth/sessions/${session.id}` as '/auth/sessions' - ); - setSessions( - sessions?.filter( - x => x.id !== session.id - ) - ); - }} - disabled={ - attemptingDelete.indexOf(session.id) > -1 - } - > - <Text id="app.settings.pages.logOut" /> - </Button> - )} - </div> - </div> - ))} - <Tip> - <span> - <Text id="app.settings.tips.sessions.a" /> - </span>{" "} - <a onClick={() => switchPage("account")}> - <Text id="app.settings.tips.sessions.b" /> - </a> - </Tip> - </div> - ); + const client = useContext(AppContext); + const deviceId = client.session?.id; + + const [sessions, setSessions] = useState<Session[] | undefined>(undefined); + const [attemptingDelete, setDelete] = useState<string[]>([]); + const history = useHistory(); + + function switchPage(to: string) { + history.replace(`/settings/${to}`); + } + + useEffect(() => { + client.req("GET", "/auth/sessions").then((data) => { + data.sort( + (a, b) => + (b.id === deviceId ? 1 : 0) - (a.id === deviceId ? 1 : 0), + ); + setSessions(data); + }); + }, []); + + if (typeof sessions === "undefined") { + return ( + <div className={styles.loader}> + <Preloader type="ring" /> + </div> + ); + } + + function getIcon(session: Session) { + const name = session.friendly_name; + switch (true) { + case /firefox/i.test(name): + return <Firefoxbrowser />; + case /chrome/i.test(name): + return <Googlechrome />; + case /safari/i.test(name): + return <Safari />; + case /edge/i.test(name): + return <Microsoftedge />; + default: + return <HelpCircle />; + } + } + + function getSystemIcon(session: Session) { + const name = session.friendly_name; + switch (true) { + case /linux/i.test(name): + return <Linux />; + case /android/i.test(name): + return <Android />; + case /mac.*os/i.test(name): + return <Macos />; + case /ios/i.test(name): + return <Ios />; + case /windows/i.test(name): + return <Windows />; + default: + return null; + } + } + + const mapped = sessions.map((session) => { + return { + ...session, + timestamp: decodeTime(session.id), + }; + }); + + mapped.sort((a, b) => b.timestamp - a.timestamp); + let id = mapped.findIndex((x) => x.id === deviceId); + + const render = [ + mapped[id], + ...mapped.slice(0, id), + ...mapped.slice(id + 1, mapped.length), + ]; + + return ( + <div className={styles.sessions}> + <h3> + <Text id="app.settings.pages.sessions.active_sessions" /> + </h3> + {render.map((session) => ( + <div + className={styles.entry} + data-active={session.id === deviceId} + data-deleting={attemptingDelete.indexOf(session.id) > -1}> + {deviceId === session.id && ( + <span className={styles.label}> + <Text id="app.settings.pages.sessions.this_device" />{" "} + </span> + )} + <div className={styles.session}> + <div className={styles.icon}> + {getIcon(session)} + <div>{getSystemIcon(session)}</div> + </div> + <div className={styles.info}> + <span className={styles.name}> + {session.friendly_name} + </span> + <span className={styles.time}> + <Text + id="app.settings.pages.sessions.created" + fields={{ + time_ago: dayjs( + session.timestamp, + ).fromNow(), + }} + /> + </span> + </div> + {deviceId !== session.id && ( + <Button + onClick={async () => { + setDelete([ + ...attemptingDelete, + session.id, + ]); + await client.req( + "DELETE", + `/auth/sessions/${session.id}` as "/auth/sessions", + ); + setSessions( + sessions?.filter( + (x) => x.id !== session.id, + ), + ); + }} + disabled={ + attemptingDelete.indexOf(session.id) > -1 + }> + <Text id="app.settings.pages.logOut" /> + </Button> + )} + </div> + </div> + ))} + <Tip> + <span> + <Text id="app.settings.tips.sessions.a" /> + </span>{" "} + <a onClick={() => switchPage("account")}> + <Text id="app.settings.tips.sessions.b" /> + </a> + </Tip> + </div> + ); } diff --git a/src/pages/settings/panes/Sync.tsx b/src/pages/settings/panes/Sync.tsx index 20d51136818b1852067c9c0967bb77991bcceaef..fec2548deb3d281cf11567d330bb5db18a4c3743 100644 --- a/src/pages/settings/panes/Sync.tsx +++ b/src/pages/settings/panes/Sync.tsx @@ -1,51 +1,56 @@ -import { Text } from "preact-i18n"; import styles from "./Panes.module.scss"; +import { Text } from "preact-i18n"; + import { dispatch } from "../../../redux"; -import Checkbox from "../../../components/ui/Checkbox"; import { connectState } from "../../../redux/connector"; import { SyncKeys, SyncOptions } from "../../../redux/reducers/sync"; +import Checkbox from "../../../components/ui/Checkbox"; + interface Props { - options?: SyncOptions; + options?: SyncOptions; } export function Component(props: Props) { - return ( - <div className={styles.notifications}> - <h3> - <Text id="app.settings.pages.sync.categories" /> - </h3> - { - ([ - ['appearance', 'appearance.title'], - ['theme', 'appearance.theme'], - ['locale', 'language.title'] - // notifications sync is always-on - ] as [ SyncKeys, string ][]).map( - ([ key, title ]) => - <Checkbox - checked={(props.options?.disabled ?? []).indexOf(key) === -1} - description={<Text id={`app.settings.pages.sync.descriptions.${key}`} />} - onChange={enabled => - dispatch({ - type: enabled ? 'SYNC_ENABLE_KEY' : 'SYNC_DISABLE_KEY', - key - }) - } - > - <Text id={`app.settings.pages.${title}`} /> - </Checkbox> - ) - } - </div> - ); + return ( + <div className={styles.notifications}> + <h3> + <Text id="app.settings.pages.sync.categories" /> + </h3> + {( + [ + ["appearance", "appearance.title"], + ["theme", "appearance.theme"], + ["locale", "language.title"], + // notifications sync is always-on + ] as [SyncKeys, string][] + ).map(([key, title]) => ( + <Checkbox + checked={ + (props.options?.disabled ?? []).indexOf(key) === -1 + } + description={ + <Text + id={`app.settings.pages.sync.descriptions.${key}`} + /> + } + onChange={(enabled) => + dispatch({ + type: enabled + ? "SYNC_ENABLE_KEY" + : "SYNC_DISABLE_KEY", + key, + }) + }> + <Text id={`app.settings.pages.${title}`} /> + </Checkbox> + ))} + </div> + ); } -export const Sync = connectState( - Component, - state => { - return { - options: state.sync - }; - } -); +export const Sync = connectState(Component, (state) => { + return { + options: state.sync, + }; +}); diff --git a/src/pages/settings/server/Bans.tsx b/src/pages/settings/server/Bans.tsx index 5ec7c0f0581a3e0eb0e9ba7b2a46053474b0f1d0..1c92eb81f7c352ee0bcd734341df82b169a1a996 100644 --- a/src/pages/settings/server/Bans.tsx +++ b/src/pages/settings/server/Bans.tsx @@ -1,25 +1,37 @@ -import Tip from "../../../components/ui/Tip"; import { Servers } from "revolt.js/dist/api/objects"; + import { useContext, useEffect, useState } from "preact/hooks"; + import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import Tip from "../../../components/ui/Tip"; + interface Props { - server: Servers.Server; + server: Servers.Server; } export function Bans({ server }: Props) { - const client = useContext(AppContext); - const [bans, setBans] = useState<Servers.Ban[] | undefined>(undefined); + const client = useContext(AppContext); + const [bans, setBans] = useState<Servers.Ban[] | undefined>(undefined); - useEffect(() => { - client.servers.fetchBans(server._id) - .then(bans => setBans(bans)) - }, [ ]); + useEffect(() => { + client.servers.fetchBans(server._id).then((bans) => setBans(bans)); + }, []); - return ( - <div> - <Tip warning>This section is under construction.</Tip> - { bans?.map(x => <div>{x._id.user}: {x.reason ?? 'no reason'} <button onClick={() => client.servers.unbanUser(server._id, x._id.user)}>unban</button></div>) } - </div> - ); + return ( + <div> + <Tip warning>This section is under construction.</Tip> + {bans?.map((x) => ( + <div> + {x._id.user}: {x.reason ?? "no reason"}{" "} + <button + onClick={() => + client.servers.unbanUser(server._id, x._id.user) + }> + unban + </button> + </div> + ))} + </div> + ); } diff --git a/src/pages/settings/server/Invites.tsx b/src/pages/settings/server/Invites.tsx index a80c07c342ac0b0694966de8491fb1fd3fcba0e0..31e9fdd4e5a502cfc817ff946d34e879eae5935f 100644 --- a/src/pages/settings/server/Invites.tsx +++ b/src/pages/settings/server/Invites.tsx @@ -1,77 +1,86 @@ -import { Text } from "preact-i18n"; -import styles from './Panes.module.scss'; import { XCircle } from "@styled-icons/boxicons-regular"; +import { Invites as InvitesNS, Servers } from "revolt.js/dist/api/objects"; + +import styles from "./Panes.module.scss"; +import { Text } from "preact-i18n"; import { useEffect, useState } from "preact/hooks"; -import Preloader from "../../../components/ui/Preloader"; -import IconButton from "../../../components/ui/IconButton"; -import UserIcon from "../../../components/common/user/UserIcon"; + +import { + useChannels, + useForceUpdate, + useUsers, +} from "../../../context/revoltjs/hooks"; import { getChannelName } from "../../../context/revoltjs/util"; -import { Invites as InvitesNS, Servers } from "revolt.js/dist/api/objects"; -import { useChannels, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; + +import UserIcon from "../../../components/common/user/UserIcon"; +import IconButton from "../../../components/ui/IconButton"; +import Preloader from "../../../components/ui/Preloader"; interface Props { - server: Servers.Server; + server: Servers.Server; } export function Invites({ server }: Props) { - const [invites, setInvites] = useState<InvitesNS.ServerInvite[] | undefined>(undefined); + const [invites, setInvites] = useState< + InvitesNS.ServerInvite[] | undefined + >(undefined); + + const ctx = useForceUpdate(); + const [deleting, setDelete] = useState<string[]>([]); + const users = useUsers(invites?.map((x) => x.creator) ?? [], ctx); + const channels = useChannels(invites?.map((x) => x.channel) ?? [], ctx); - const ctx = useForceUpdate(); - const [deleting, setDelete] = useState<string[]>([]); - const users = useUsers(invites?.map(x => x.creator) ?? [], ctx); - const channels = useChannels(invites?.map(x => x.channel) ?? [], ctx); + useEffect(() => { + ctx.client.servers + .fetchInvites(server._id) + .then((invites) => setInvites(invites)); + }, []); - useEffect(() => { - ctx.client.servers.fetchInvites(server._id) - .then(invites => setInvites(invites)) - }, [ ]); + return ( + <div className={styles.invites}> + <div className={styles.subtitle}> + <span>Invite Code</span> + <span>Invitor</span> + <span>Channel</span> + <span>Revoke</span> + </div> + {typeof invites === "undefined" && <Preloader type="ring" />} + {invites?.map((invite) => { + let creator = users.find((x) => x?._id === invite.creator); + let channel = channels.find((x) => x?._id === invite.channel); - return ( - <div className={styles.invites}> - <div className={styles.subtitle}> - <span>Invite Code</span> - <span>Invitor</span> - <span>Channel</span> - <span>Revoke</span> - </div> - { typeof invites === 'undefined' && <Preloader type="ring" /> } - { - invites?.map( - invite => { - let creator = users.find(x => x?._id === invite.creator); - let channel = channels.find(x => x?._id === invite.channel); + return ( + <div + className={styles.invite} + data-deleting={deleting.indexOf(invite._id) > -1}> + <code>{invite._id}</code> + <span> + <UserIcon target={creator} size={24} />{" "} + {creator?.username ?? "unknown"} + </span> + <span> + {channel && creator + ? getChannelName(ctx.client, channel, true) + : "#unknown"} + </span> + <IconButton + onClick={async () => { + setDelete([...deleting, invite._id]); - return ( - <div className={styles.invite} - data-deleting={deleting.indexOf(invite._id) > -1}> - <code>{ invite._id }</code> - <span> - <UserIcon target={creator} size={24} /> {creator?.username ?? 'unknown'} - </span> - <span>{ (channel && creator) ? getChannelName(ctx.client, channel, true) : '#unknown' }</span> - <IconButton - onClick={async () => { - setDelete([ - ...deleting, - invite._id - ]); + await ctx.client.deleteInvite(invite._id); - await ctx.client.deleteInvite(invite._id); - - setInvites( - invites?.filter( - x => x._id !== invite._id - ) - ); - }} - disabled={deleting.indexOf(invite._id) > -1}> - <XCircle size={24} /> - </IconButton> - </div> - ) - } - ) - } - </div> - ); + setInvites( + invites?.filter( + (x) => x._id !== invite._id, + ), + ); + }} + disabled={deleting.indexOf(invite._id) > -1}> + <XCircle size={24} /> + </IconButton> + </div> + ); + })} + </div> + ); } diff --git a/src/pages/settings/server/Members.tsx b/src/pages/settings/server/Members.tsx index 8f0c171061e8caf37e54e7ff9ac7335fe824f10b..faff176047316e422e39aac488bb2def5f665313 100644 --- a/src/pages/settings/server/Members.tsx +++ b/src/pages/settings/server/Members.tsx @@ -1,34 +1,44 @@ -import styles from './Panes.module.scss'; -import { useEffect, useState } from "preact/hooks"; import { Servers } from "revolt.js/dist/api/objects"; + +import styles from "./Panes.module.scss"; +import { useEffect, useState } from "preact/hooks"; + import { useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; interface Props { - server: Servers.Server; + server: Servers.Server; } // ! FIXME: bad code :) export function Members({ server }: Props) { - const [members, setMembers] = useState<Servers.Member[] | undefined>(undefined); + const [members, setMembers] = useState<Servers.Member[] | undefined>( + undefined, + ); - const ctx = useForceUpdate(); - const users = useUsers(members?.map(x => x._id.user) ?? [], ctx); + const ctx = useForceUpdate(); + const users = useUsers(members?.map((x) => x._id.user) ?? [], ctx); - useEffect(() => { - ctx.client.servers.members.fetchMembers(server._id) - .then(members => setMembers(members)) - }, [ ]); + useEffect(() => { + ctx.client.servers.members + .fetchMembers(server._id) + .then((members) => setMembers(members)); + }, []); - return ( - <div className={styles.members}> - <div className={styles.subtitle}> - { members?.length ?? 0 } Members - </div> - { members && members.length > 0 && users?.map(x => x && - <div className={styles.member}> - <div>@{x.username}</div> - </div>) - } - </div> - ); + return ( + <div className={styles.members}> + <div className={styles.subtitle}> + {members?.length ?? 0} Members + </div> + {members && + members.length > 0 && + users?.map( + (x) => + x && ( + <div className={styles.member}> + <div>@{x.username}</div> + </div> + ), + )} + </div> + ); } diff --git a/src/pages/settings/server/Overview.tsx b/src/pages/settings/server/Overview.tsx index 849e0e01517e595f002ae37042e4b787dfd35181..2f32ee73057a39a0ba1e66dec29f2db49c93b668 100644 --- a/src/pages/settings/server/Overview.tsx +++ b/src/pages/settings/server/Overview.tsx @@ -1,142 +1,193 @@ -import { Text } from "preact-i18n"; import isEqual from "lodash.isequal"; -import styles from './Panes.module.scss'; -import Button from "../../../components/ui/Button"; -import InputBox from "../../../components/ui/InputBox"; -import ComboBox from "../../../components/ui/ComboBox"; import { Servers, Server } from "revolt.js/dist/api/objects"; -import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; + +import styles from "./Panes.module.scss"; +import { Text } from "preact-i18n"; import { useContext, useEffect, useState } from "preact/hooks"; -import { getChannelName } from "../../../context/revoltjs/util"; -import { AppContext } from "../../../context/revoltjs/RevoltClient"; + +import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; + import { FileUploader } from "../../../context/revoltjs/FileUploads"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { getChannelName } from "../../../context/revoltjs/util"; + +import Button from "../../../components/ui/Button"; +import ComboBox from "../../../components/ui/ComboBox"; +import InputBox from "../../../components/ui/InputBox"; interface Props { - server: Servers.Server; + server: Servers.Server; } export function Overview({ server }: Props) { - const client = useContext(AppContext); + const client = useContext(AppContext); + + const [name, setName] = useState(server.name); + const [description, setDescription] = useState(server.description ?? ""); + const [systemMessages, setSystemMessages] = useState( + server.system_messages, + ); - const [name, setName] = useState(server.name); - const [description, setDescription] = useState(server.description ?? ''); - const [systemMessages, setSystemMessages] = useState(server.system_messages); + useEffect(() => setName(server.name), [server.name]); + useEffect( + () => setDescription(server.description ?? ""), + [server.description], + ); + useEffect( + () => setSystemMessages(server.system_messages), + [server.system_messages], + ); - useEffect(() => setName(server.name), [ server.name ]); - useEffect(() => setDescription(server.description ?? ''), [ server.description ]); - useEffect(() => setSystemMessages(server.system_messages), [ server.system_messages ]); + const [changed, setChanged] = useState(false); + function save() { + let changes: Partial< + Pick<Servers.Server, "name" | "description" | "system_messages"> + > = {}; + if (name !== server.name) changes.name = name; + if (description !== server.description) + changes.description = description; + if (!isEqual(systemMessages, server.system_messages)) + changes.system_messages = systemMessages; - const [ changed, setChanged ] = useState(false); - function save() { - let changes: Partial<Pick<Servers.Server, 'name' | 'description' | 'system_messages'>> = {}; - if (name !== server.name) changes.name = name; - if (description !== server.description) changes.description = description; - if (!isEqual(systemMessages, server.system_messages)) changes.system_messages = systemMessages; - - client.servers.edit(server._id, changes); - setChanged(false); - } + client.servers.edit(server._id, changes); + setChanged(false); + } - return ( - <div className={styles.overview}> - <div className={styles.row}> - <FileUploader - width={80} - height={80} - style="icon" - fileType="icons" - behaviour="upload" - maxFileSize={2_500_000} - onUpload={icon => client.servers.edit(server._id, { icon })} - previewURL={client.servers.getIconURL(server._id, { max_side: 256 }, true)} - remove={() => client.servers.edit(server._id, { remove: 'Icon' })} - /> - <div className={styles.name}> - <h3> - <Text id="app.main.servers.name" /> - </h3> - <InputBox - contrast - value={name} - maxLength={32} - onChange={e => { - setName(e.currentTarget.value) - if (!changed) setChanged(true) - }} - /> - </div> - </div> + return ( + <div className={styles.overview}> + <div className={styles.row}> + <FileUploader + width={80} + height={80} + style="icon" + fileType="icons" + behaviour="upload" + maxFileSize={2_500_000} + onUpload={(icon) => + client.servers.edit(server._id, { icon }) + } + previewURL={client.servers.getIconURL( + server._id, + { max_side: 256 }, + true, + )} + remove={() => + client.servers.edit(server._id, { remove: "Icon" }) + } + /> + <div className={styles.name}> + <h3> + <Text id="app.main.servers.name" /> + </h3> + <InputBox + contrast + value={name} + maxLength={32} + onChange={(e) => { + setName(e.currentTarget.value); + if (!changed) setChanged(true); + }} + /> + </div> + </div> - <h3> - <Text id="app.main.servers.description" /> - </h3> - <TextAreaAutoSize - maxRows={10} - minHeight={60} - maxLength={1024} - value={description} - placeholder={"Add a topic..."} - onChange={ev => { - setDescription(ev.currentTarget.value); - if (!changed) setChanged(true) - }} - /> + <h3> + <Text id="app.main.servers.description" /> + </h3> + <TextAreaAutoSize + maxRows={10} + minHeight={60} + maxLength={1024} + value={description} + placeholder={"Add a topic..."} + onChange={(ev) => { + setDescription(ev.currentTarget.value); + if (!changed) setChanged(true); + }} + /> - <h3> - <Text id="app.main.servers.custom_banner" /> - </h3> - <FileUploader - height={160} - style="banner" - fileType="banners" - behaviour="upload" - maxFileSize={6_000_000} - onUpload={banner => client.servers.edit(server._id, { banner })} - previewURL={client.servers.getBannerURL(server._id, { width: 1000 }, true)} - remove={() => client.servers.edit(server._id, { remove: 'Banner' })} - /> + <h3> + <Text id="app.main.servers.custom_banner" /> + </h3> + <FileUploader + height={160} + style="banner" + fileType="banners" + behaviour="upload" + maxFileSize={6_000_000} + onUpload={(banner) => + client.servers.edit(server._id, { banner }) + } + previewURL={client.servers.getBannerURL( + server._id, + { width: 1000 }, + true, + )} + remove={() => + client.servers.edit(server._id, { remove: "Banner" }) + } + /> - <h3> - <Text id="app.settings.server_pages.overview.system_messages" /> - </h3> - {[ - [ 'User Joined', 'user_joined' ], - [ 'User Left', 'user_left' ], - [ 'User Kicked', 'user_kicked' ], - [ 'User Banned', 'user_banned' ] - ].map(([ i18n, key ]) => - // ! FIXME: temporary code just so we can expose the options - <p style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> - <span style={{ flexShrink: '0', flex: `25%` }}>{i18n}</span> - <ComboBox value={systemMessages?.[key as keyof typeof systemMessages] ?? 'disabled'} - onChange={e => { - if (!changed) setChanged(true) - const v = e.currentTarget.value; - if (v === 'disabled') { - const { [key as keyof typeof systemMessages]: _, ...other } = systemMessages; - setSystemMessages(other); - } else { - setSystemMessages({ - ...systemMessages, - [key]: v - }); - } - }}> - <option value='disabled'><Text id="general.disabled" /></option> - { server.channels.map(id => { - const channel = client.channels.get(id); - if (!channel) return null; - return <option value={id}>{ getChannelName(client, channel, true) }</option>; - }) } - </ComboBox> - </p> - )} + <h3> + <Text id="app.settings.server_pages.overview.system_messages" /> + </h3> + {[ + ["User Joined", "user_joined"], + ["User Left", "user_left"], + ["User Kicked", "user_kicked"], + ["User Banned", "user_banned"], + ].map(([i18n, key]) => ( + // ! FIXME: temporary code just so we can expose the options + <p + style={{ + display: "flex", + gap: "8px", + alignItems: "center", + }}> + <span style={{ flexShrink: "0", flex: `25%` }}>{i18n}</span> + <ComboBox + value={ + systemMessages?.[ + key as keyof typeof systemMessages + ] ?? "disabled" + } + onChange={(e) => { + if (!changed) setChanged(true); + const v = e.currentTarget.value; + if (v === "disabled") { + const { + [key as keyof typeof systemMessages]: _, + ...other + } = systemMessages; + setSystemMessages(other); + } else { + setSystemMessages({ + ...systemMessages, + [key]: v, + }); + } + }}> + <option value="disabled"> + <Text id="general.disabled" /> + </option> + {server.channels.map((id) => { + const channel = client.channels.get(id); + if (!channel) return null; + return ( + <option value={id}> + {getChannelName(client, channel, true)} + </option> + ); + })} + </ComboBox> + </p> + ))} - <p> - <Button onClick={save} contrast disabled={!changed}> - <Text id="app.special.modals.actions.save" /> - </Button> - </p> - </div> - ); + <p> + <Button onClick={save} contrast disabled={!changed}> + <Text id="app.special.modals.actions.save" /> + </Button> + </p> + </div> + ); } diff --git a/src/pages/settings/server/Roles.tsx b/src/pages/settings/server/Roles.tsx index a139192903294e947e5959ae72147989b164e0a4..78f9b8113f35a90744cf45da21fc3418e02f6e65 100644 --- a/src/pages/settings/server/Roles.tsx +++ b/src/pages/settings/server/Roles.tsx @@ -1,120 +1,179 @@ -import { Text } from "preact-i18n"; -import styles from './Panes.module.scss'; -import Button from "../../../components/ui/Button"; -import Overline from "../../../components/ui/Overline"; +import { Plus } from "@styled-icons/boxicons-regular"; +import isEqual from "lodash.isequal"; import { Servers } from "revolt.js/dist/api/objects"; -import Checkbox from "../../../components/ui/Checkbox"; +import { + ChannelPermission, + ServerPermission, +} from "revolt.js/dist/api/permissions"; + +import styles from "./Panes.module.scss"; +import { Text } from "preact-i18n"; import { useContext, useEffect, useState } from "preact/hooks"; + +import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { AppContext } from "../../../context/revoltjs/RevoltClient"; -import { ChannelPermission, ServerPermission } from "revolt.js/dist/api/permissions"; -import Tip from "../../../components/ui/Tip"; + +import Button from "../../../components/ui/Button"; +import Checkbox from "../../../components/ui/Checkbox"; import IconButton from "../../../components/ui/IconButton"; -import ButtonItem from "../../../components/navigation/items/ButtonItem"; -import isEqual from 'lodash.isequal'; import InputBox from "../../../components/ui/InputBox"; -import { Plus } from "@styled-icons/boxicons-regular"; -import { useIntermediate } from "../../../context/intermediate/Intermediate"; +import Overline from "../../../components/ui/Overline"; +import Tip from "../../../components/ui/Tip"; + +import ButtonItem from "../../../components/navigation/items/ButtonItem"; interface Props { - server: Servers.Server; + server: Servers.Server; } -const I32ToU32 = (arr: number[]) => arr.map(x => x >>> 0); +const I32ToU32 = (arr: number[]) => arr.map((x) => x >>> 0); // ! FIXME: bad code :) export function Roles({ server }: Props) { - const [ role, setRole ] = useState('default'); - const { openScreen } = useIntermediate(); - const client = useContext(AppContext); - const roles = server.roles ?? {}; + const [role, setRole] = useState("default"); + const { openScreen } = useIntermediate(); + const client = useContext(AppContext); + const roles = server.roles ?? {}; + + if (role !== "default" && typeof roles[role] === "undefined") { + useEffect(() => setRole("default")); + return null; + } - if (role !== 'default' && typeof roles[role] === 'undefined') { - useEffect(() => setRole('default')); - return null; - } + const v = (id: string) => + I32ToU32( + id === "default" + ? server.default_permissions + : roles[id].permissions, + ); + const [perm, setPerm] = useState(v(role)); + useEffect(() => setPerm(v(role)), [role, roles[role]?.permissions]); - const v = (id: string) => I32ToU32(id === 'default' ? server.default_permissions : roles[id].permissions) - const [ perm, setPerm ] = useState(v(role)); - useEffect(() => setPerm(v(role)), [ role, roles[role]?.permissions ]); + const modified = !isEqual(perm, v(role)); + const save = () => + client.servers.setPermissions(server._id, role, { + server: perm[0], + channel: perm[1], + }); + const deleteRole = () => { + setRole("default"); + client.servers.deleteRole(server._id, role); + }; - const modified = !isEqual(perm, v(role)); - const save = () => client.servers.setPermissions(server._id, role, { server: perm[0], channel: perm[1] }); - const deleteRole = () => { - setRole('default'); - client.servers.deleteRole(server._id, role); - }; - - return ( - <div className={styles.roles}> - <div className={styles.list}> - <div className={styles.title}> - <h1><Text id="app.settings.server_pages.roles.title" /></h1> - <Plus size={22} onClick={() => - openScreen({ id: 'special_input', type: 'create_role', server: server._id, callback: id => setRole(id) })} /> - </div> - { [ 'default', ...Object.keys(roles) ] - .map(id => { - if (id === 'default') { - return ( - <ButtonItem active={role === 'default'} onClick={() => setRole('default')}> - <Text id="app.settings.permissions.default_role" /> - </ButtonItem> - ) - } else { - return ( - <ButtonItem active={role === id} onClick={() => setRole(id)}> - { roles[id].name } - </ButtonItem> - ) - } - }) - } - </div> - <div className={styles.permissions}> - <div className={styles.title}> - <h2>{ role === 'default' ? <Text id="app.settings.permissions.default_role" /> : roles[role].name }</h2> - <Button contrast disabled={!modified} onClick={save}>Save</Button> - </div> - <section> - <Overline type="subtle"><Text id="app.settings.permissions.server" /></Overline> - { Object.keys(ServerPermission) - .map(key => { - if (key === 'View') return; - let value = ServerPermission[key as keyof typeof ServerPermission]; + return ( + <div className={styles.roles}> + <div className={styles.list}> + <div className={styles.title}> + <h1> + <Text id="app.settings.server_pages.roles.title" /> + </h1> + <Plus + size={22} + onClick={() => + openScreen({ + id: "special_input", + type: "create_role", + server: server._id, + callback: (id) => setRole(id), + }) + } + /> + </div> + {["default", ...Object.keys(roles)].map((id) => { + if (id === "default") { + return ( + <ButtonItem + active={role === "default"} + onClick={() => setRole("default")}> + <Text id="app.settings.permissions.default_role" /> + </ButtonItem> + ); + } else { + return ( + <ButtonItem + active={role === id} + onClick={() => setRole(id)}> + {roles[id].name} + </ButtonItem> + ); + } + })} + </div> + <div className={styles.permissions}> + <div className={styles.title}> + <h2> + {role === "default" ? ( + <Text id="app.settings.permissions.default_role" /> + ) : ( + roles[role].name + )} + </h2> + <Button contrast disabled={!modified} onClick={save}> + Save + </Button> + </div> + <section> + <Overline type="subtle"> + <Text id="app.settings.permissions.server" /> + </Overline> + {Object.keys(ServerPermission).map((key) => { + if (key === "View") return; + let value = + ServerPermission[ + key as keyof typeof ServerPermission + ]; - return ( - <Checkbox checked={(perm[0] & value) > 0} - onChange={() => setPerm([ perm[0] ^ value, perm[1] ])} - description={<Text id={`permissions.server.${key}.d`} />}> - <Text id={`permissions.server.${key}.t`} /> - </Checkbox> - ) - }) - } - </section> - <section> - <Overline type="subtle"><Text id="app.settings.permissions.channel" /></Overline> - { Object.keys(ChannelPermission) - .map(key => { - if (key === 'ManageChannel') return; - let value = ChannelPermission[key as keyof typeof ChannelPermission]; + return ( + <Checkbox + checked={(perm[0] & value) > 0} + onChange={() => + setPerm([perm[0] ^ value, perm[1]]) + } + description={ + <Text id={`permissions.server.${key}.d`} /> + }> + <Text id={`permissions.server.${key}.t`} /> + </Checkbox> + ); + })} + </section> + <section> + <Overline type="subtle"> + <Text id="app.settings.permissions.channel" /> + </Overline> + {Object.keys(ChannelPermission).map((key) => { + if (key === "ManageChannel") return; + let value = + ChannelPermission[ + key as keyof typeof ChannelPermission + ]; - return ( - <Checkbox checked={((perm[1] >>> 0) & value) > 0} - onChange={() => setPerm([ perm[0], perm[1] ^ value ])} - disabled={key === 'View'} - description={<Text id={`permissions.channel.${key}.d`} />}> - <Text id={`permissions.channel.${key}.t`} /> - </Checkbox> - ) - }) - } - </section> - <div className={styles.actions}> - <Button contrast disabled={!modified} onClick={save}>Save</Button> - { role !== 'default' && <Button contrast error onClick={deleteRole}>Delete</Button> } - </div> - </div> - </div> - ); + return ( + <Checkbox + checked={((perm[1] >>> 0) & value) > 0} + onChange={() => + setPerm([perm[0], perm[1] ^ value]) + } + disabled={key === "View"} + description={ + <Text id={`permissions.channel.${key}.d`} /> + }> + <Text id={`permissions.channel.${key}.t`} /> + </Checkbox> + ); + })} + </section> + <div className={styles.actions}> + <Button contrast disabled={!modified} onClick={save}> + Save + </Button> + {role !== "default" && ( + <Button contrast error onClick={deleteRole}> + Delete + </Button> + )} + </div> + </div> + </div> + ); } diff --git a/src/redux/State.tsx b/src/redux/State.tsx index 2b3576ff463f5aef876a5963c455bd5f94d17215..7bc87e12d676ddeac2844a467b0a325df626c5ed 100644 --- a/src/redux/State.tsx +++ b/src/redux/State.tsx @@ -1,27 +1,28 @@ import localForage from "localforage"; import { Provider } from "react-redux"; -import { Children } from "../types/Preact"; -import { dispatch, State, store } from "."; + import { useEffect, useState } from "preact/hooks"; +import { dispatch, State, store } from "."; +import { Children } from "../types/Preact"; + interface Props { - children: Children; + children: Children; } -export default function State(props: Props) { - const [loaded, setLoaded] = useState(false); +export default function StateLoader(props: Props) { + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + localForage.getItem("state").then((state) => { + if (state !== null) { + dispatch({ type: "__INIT", state: state as State }); + } - useEffect(() => { - localForage.getItem("state") - .then(state => { - if (state !== null) { - dispatch({ type: "__INIT", state: state as State }); - } - - setLoaded(true); - }); - }, []); + setLoaded(true); + }); + }, []); - if (!loaded) return null; - return <Provider store={store}>{props.children}</Provider>; + if (!loaded) return null; + return <Provider store={store}>{props.children}</Provider>; } diff --git a/src/redux/connector.tsx b/src/redux/connector.tsx index 6d9075cabcc0dd6891450442685edb1d5f3699c8..16eef2e7cdcfe75b47c110a63afd16c5fb837c5a 100644 --- a/src/redux/connector.tsx +++ b/src/redux/connector.tsx @@ -1,15 +1,16 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { connect, ConnectedComponent } from "react-redux"; -import { State } from "."; import { h } from "preact"; import { memo } from "preact/compat"; -import { connect, ConnectedComponent } from "react-redux"; + +import { State } from "."; export function connectState<T>( - component: (props: any) => h.JSX.Element | null, - mapKeys: (state: State, props: T) => any, - memoize?: boolean + component: (props: any) => h.JSX.Element | null, + mapKeys: (state: State, props: T) => any, + memoize?: boolean, ): ConnectedComponent<(props: any) => h.JSX.Element | null, T> { - let c = connect(mapKeys)(component); - return memoize ? memo(c) : c; + let c = connect(mapKeys)(component); + return memoize ? memo(c) : c; } diff --git a/src/redux/index.ts b/src/redux/index.ts index e57d60ea1cc47d64e1b785bb60e060b73b20e389..b1969de87d30063fae1a490f5594ef3029d9fbaa 100644 --- a/src/redux/index.ts +++ b/src/redux/index.ts @@ -1,83 +1,84 @@ -import { createStore } from "redux"; import localForage from "localforage"; -import rootReducer, { Action } from "./reducers"; - +import { createStore } from "redux"; import { Core } from "revolt.js/dist/api/objects"; -import { Typing } from "./reducers/typing"; -import { Drafts } from "./reducers/drafts"; -import { AuthState } from "./reducers/auth"; + import { Language } from "../context/Locale"; -import { Unreads } from "./reducers/unreads"; -import { SyncOptions } from "./reducers/sync"; -import { Settings } from "./reducers/settings"; -import { QueuedMessage } from "./reducers/queue"; + +import rootReducer, { Action } from "./reducers"; +import { AuthState } from "./reducers/auth"; +import { Drafts } from "./reducers/drafts"; import { ExperimentOptions } from "./reducers/experiments"; import { LastOpened } from "./reducers/last_opened"; import { Notifications } from "./reducers/notifications"; +import { QueuedMessage } from "./reducers/queue"; import { SectionToggle } from "./reducers/section_toggle"; +import { Settings } from "./reducers/settings"; +import { SyncOptions } from "./reducers/sync"; +import { Typing } from "./reducers/typing"; +import { Unreads } from "./reducers/unreads"; export type State = { - config: Core.RevoltNodeConfiguration, - locale: Language; - auth: AuthState; - settings: Settings; - unreads: Unreads; - queue: QueuedMessage[]; - typing: Typing; - drafts: Drafts; - sync: SyncOptions; - experiments: ExperimentOptions; - lastOpened: LastOpened; - notifications: Notifications; - sectionToggle: SectionToggle; + config: Core.RevoltNodeConfiguration; + locale: Language; + auth: AuthState; + settings: Settings; + unreads: Unreads; + queue: QueuedMessage[]; + typing: Typing; + drafts: Drafts; + sync: SyncOptions; + experiments: ExperimentOptions; + lastOpened: LastOpened; + notifications: Notifications; + sectionToggle: SectionToggle; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const store = createStore((state: any, action: any) => { - if (import.meta.env.DEV) { - console.debug("State Update:", action); - } + if (import.meta.env.DEV) { + console.debug("State Update:", action); + } - if (action.type === "__INIT") { - return action.state; - } + if (action.type === "__INIT") { + return action.state; + } - return rootReducer(state, action); + return rootReducer(state, action); }); // Save state using localForage. store.subscribe(() => { - const { - config, - locale, - auth, - settings, - unreads, - queue, - drafts, - sync, - experiments, - lastOpened, - notifications, - sectionToggle - } = store.getState() as State; + const { + config, + locale, + auth, + settings, + unreads, + queue, + drafts, + sync, + experiments, + lastOpened, + notifications, + sectionToggle, + } = store.getState() as State; - localForage.setItem("state", { - config, - locale, - auth, - settings, - unreads, - queue, - drafts, - sync, - experiments, - lastOpened, - notifications, - sectionToggle - }); + localForage.setItem("state", { + config, + locale, + auth, + settings, + unreads, + queue, + drafts, + sync, + experiments, + lastOpened, + notifications, + sectionToggle, + }); }); export function dispatch(action: Action) { - store.dispatch(action); + store.dispatch(action); } diff --git a/src/redux/reducers/auth.ts b/src/redux/reducers/auth.ts index c309e4f833a7a1385a544bc51529a070462db0b2..a91a88ec2e893b5249919f57f4547edc83146110 100644 --- a/src/redux/reducers/auth.ts +++ b/src/redux/reducers/auth.ts @@ -1,49 +1,49 @@ import type { Auth } from "revolt.js/dist/api/objects"; export interface AuthState { - accounts: { - [key: string]: { - session: Auth.Session; - }; - }; - active?: string; + accounts: { + [key: string]: { + session: Auth.Session; + }; + }; + active?: string; } export type AuthAction = - | { type: undefined } - | { - type: "LOGIN"; - session: Auth.Session; - } - | { - type: "LOGOUT"; - user_id?: string; - }; + | { type: undefined } + | { + type: "LOGIN"; + session: Auth.Session; + } + | { + type: "LOGOUT"; + user_id?: string; + }; export function auth( - state = { accounts: {} } as AuthState, - action: AuthAction + state = { accounts: {} } as AuthState, + action: AuthAction, ): AuthState { - switch (action.type) { - case "LOGIN": - return { - accounts: { - ...state.accounts, - [action.session.user_id]: { - session: action.session, - }, - }, - active: action.session.user_id, - }; - case "LOGOUT": { - const accounts = Object.assign({}, state.accounts); - action.user_id && delete accounts[action.user_id]; + switch (action.type) { + case "LOGIN": + return { + accounts: { + ...state.accounts, + [action.session.user_id]: { + session: action.session, + }, + }, + active: action.session.user_id, + }; + case "LOGOUT": { + const accounts = Object.assign({}, state.accounts); + action.user_id && delete accounts[action.user_id]; - return { - accounts, - }; - } - default: - return state; - } + return { + accounts, + }; + } + default: + return state; + } } diff --git a/src/redux/reducers/drafts.ts b/src/redux/reducers/drafts.ts index 4f36a84655c7f2a5526674926f75e63133fcb7cc..d34c29bdcfade54ae17a390a3521031c753e7d0f 100644 --- a/src/redux/reducers/drafts.ts +++ b/src/redux/reducers/drafts.ts @@ -1,35 +1,35 @@ export type Drafts = { [key: string]: string }; export type DraftAction = - | { type: undefined } - | { - type: "SET_DRAFT"; - channel: string; - content: string; - } - | { - type: "CLEAR_DRAFT"; - channel: string; - } - | { - type: "RESET"; - }; + | { type: undefined } + | { + type: "SET_DRAFT"; + channel: string; + content: string; + } + | { + type: "CLEAR_DRAFT"; + channel: string; + } + | { + type: "RESET"; + }; export function drafts(state: Drafts = {}, action: DraftAction): Drafts { - switch (action.type) { - case "SET_DRAFT": - return { - ...state, - [action.channel]: action.content, - }; - case "CLEAR_DRAFT": { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { [action.channel]: _, ...newState } = state; - return newState; - } - case "RESET": - return {}; - default: - return state; - } + switch (action.type) { + case "SET_DRAFT": + return { + ...state, + [action.channel]: action.content, + }; + case "CLEAR_DRAFT": { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [action.channel]: _, ...newState } = state; + return newState; + } + case "RESET": + return {}; + default: + return state; + } } diff --git a/src/redux/reducers/experiments.ts b/src/redux/reducers/experiments.ts index fa3adaddd570be3f7d4a513d78cb85e951538ea8..1e3b90891f3ab7794756b2b5e6c5dca605f5d317 100644 --- a/src/redux/reducers/experiments.ts +++ b/src/redux/reducers/experiments.ts @@ -2,43 +2,43 @@ export type Experiments = never; export const AVAILABLE_EXPERIMENTS: Experiments[] = []; export interface ExperimentOptions { - enabled?: Experiments[]; + enabled?: Experiments[]; } export type ExperimentsAction = - | { type: undefined } - | { - type: "EXPERIMENTS_ENABLE"; - key: Experiments; - } - | { - type: "EXPERIMENTS_DISABLE"; - key: Experiments; - }; + | { type: undefined } + | { + type: "EXPERIMENTS_ENABLE"; + key: Experiments; + } + | { + type: "EXPERIMENTS_DISABLE"; + key: Experiments; + }; export function experiments( - state = {} as ExperimentOptions, - action: ExperimentsAction + state = {} as ExperimentOptions, + action: ExperimentsAction, ): ExperimentOptions { - switch (action.type) { - case "EXPERIMENTS_ENABLE": - return { - ...state, - enabled: [ - ...(state.enabled ?? []) - .filter((x) => AVAILABLE_EXPERIMENTS.includes(x)) - .filter((v) => v !== action.key), - action.key, - ], - }; - case "EXPERIMENTS_DISABLE": - return { - ...state, - enabled: state.enabled - ?.filter((v) => v !== action.key) - .filter((x) => AVAILABLE_EXPERIMENTS.includes(x)), - }; - default: - return state; - } + switch (action.type) { + case "EXPERIMENTS_ENABLE": + return { + ...state, + enabled: [ + ...(state.enabled ?? []) + .filter((x) => AVAILABLE_EXPERIMENTS.includes(x)) + .filter((v) => v !== action.key), + action.key, + ], + }; + case "EXPERIMENTS_DISABLE": + return { + ...state, + enabled: state.enabled + ?.filter((v) => v !== action.key) + .filter((x) => AVAILABLE_EXPERIMENTS.includes(x)), + }; + default: + return state; + } } diff --git a/src/redux/reducers/index.ts b/src/redux/reducers/index.ts index 15b7e36294bb0e4a0e21592e5353ad1e241edd9a..83e3fb3fdc5cf8444bdb83b89ba7ef395b6294df 100644 --- a/src/redux/reducers/index.ts +++ b/src/redux/reducers/index.ts @@ -1,60 +1,60 @@ -import { State } from ".."; import { combineReducers } from "redux"; -import { config, ConfigAction } from "./server_config"; -import { settings, SettingsAction } from "./settings"; -import { locale, LocaleAction } from "./locale"; +import { State } from ".."; import { auth, AuthAction } from "./auth"; -import { unreads, UnreadsAction } from "./unreads"; -import { queue, QueueAction } from "./queue"; -import { typing, TypingAction } from "./typing"; import { drafts, DraftAction } from "./drafts"; -import { sync, SyncAction } from "./sync"; import { experiments, ExperimentsAction } from "./experiments"; import { lastOpened, LastOpenedAction } from "./last_opened"; +import { locale, LocaleAction } from "./locale"; import { notifications, NotificationsAction } from "./notifications"; +import { queue, QueueAction } from "./queue"; import { sectionToggle, SectionToggleAction } from "./section_toggle"; +import { config, ConfigAction } from "./server_config"; +import { settings, SettingsAction } from "./settings"; +import { sync, SyncAction } from "./sync"; +import { typing, TypingAction } from "./typing"; +import { unreads, UnreadsAction } from "./unreads"; export default combineReducers({ - config, - locale, - auth, - settings, - unreads, - queue, - typing, - drafts, - sync, - experiments, - lastOpened, - notifications, - sectionToggle + config, + locale, + auth, + settings, + unreads, + queue, + typing, + drafts, + sync, + experiments, + lastOpened, + notifications, + sectionToggle, }); export type Action = - | ConfigAction - | LocaleAction - | AuthAction - | SettingsAction - | UnreadsAction - | QueueAction - | TypingAction - | DraftAction - | SyncAction - | ExperimentsAction - | LastOpenedAction - | NotificationsAction - | SectionToggleAction - | { type: "__INIT"; state: State }; + | ConfigAction + | LocaleAction + | AuthAction + | SettingsAction + | UnreadsAction + | QueueAction + | TypingAction + | DraftAction + | SyncAction + | ExperimentsAction + | LastOpenedAction + | NotificationsAction + | SectionToggleAction + | { type: "__INIT"; state: State }; // eslint-disable-next-line @typescript-eslint/no-explicit-any export function filter(obj: any, keys: string[]) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const newObj: any = {}; - for (const key of keys) { - const v = obj[key]; - if (v) newObj[key] = v; - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newObj: any = {}; + for (const key of keys) { + const v = obj[key]; + if (v) newObj[key] = v; + } - return newObj; + return newObj; } diff --git a/src/redux/reducers/last_opened.ts b/src/redux/reducers/last_opened.ts index 0b29a156acba4cefe70d7aae7f653464184db98d..b4c89d18837c27d8098c5df392f049f27925de88 100644 --- a/src/redux/reducers/last_opened.ts +++ b/src/redux/reducers/last_opened.ts @@ -1,29 +1,32 @@ export interface LastOpened { - [key: string]: string + [key: string]: string; } export type LastOpenedAction = - | { type: undefined } - | { - type: "LAST_OPENED_SET"; - parent: string; - child: string; - } - | { - type: "RESET"; - }; + | { type: undefined } + | { + type: "LAST_OPENED_SET"; + parent: string; + child: string; + } + | { + type: "RESET"; + }; -export function lastOpened(state = {} as LastOpened, action: LastOpenedAction): LastOpened { - switch (action.type) { - case "LAST_OPENED_SET": { - return { - ...state, - [action.parent]: action.child - } - } - case "RESET": - return {}; - default: - return state; - } +export function lastOpened( + state = {} as LastOpened, + action: LastOpenedAction, +): LastOpened { + switch (action.type) { + case "LAST_OPENED_SET": { + return { + ...state, + [action.parent]: action.child, + }; + } + case "RESET": + return {}; + default: + return state; + } } diff --git a/src/redux/reducers/locale.ts b/src/redux/reducers/locale.ts index 6146dd53e6d3cb5e529f95b2fe17f2fda2d21a70..fd695a1e463ea08ee32d3e495a1ac109b6a0a875 100644 --- a/src/redux/reducers/locale.ts +++ b/src/redux/reducers/locale.ts @@ -1,51 +1,52 @@ import { Language } from "../../context/Locale"; + import type { SyncUpdateAction } from "./sync"; export type LocaleAction = - | { type: undefined } - | { - type: "SET_LOCALE"; - locale: Language; - } - | SyncUpdateAction; + | { type: undefined } + | { + type: "SET_LOCALE"; + locale: Language; + } + | SyncUpdateAction; export function findLanguage(lang?: string): Language { - if (!lang) { - if (typeof navigator === "undefined") { - lang = Language.ENGLISH; - } else { - lang = navigator.language; - } - } - - const code = lang.replace("-", "_"); - const short = code.split("_")[0]; - - const values = []; - for (const key in Language) { - const value = Language[key as keyof typeof Language]; - values.push(value); - if (value.startsWith(code)) { - return value as Language; - } - } - - for (const value of values.reverse()) { - if (value.startsWith(short)) { - return value as Language; - } - } - - return Language.ENGLISH; + if (!lang) { + if (typeof navigator === "undefined") { + lang = Language.ENGLISH; + } else { + lang = navigator.language; + } + } + + const code = lang.replace("-", "_"); + const short = code.split("_")[0]; + + const values = []; + for (const key in Language) { + const value = Language[key as keyof typeof Language]; + values.push(value); + if (value.startsWith(code)) { + return value as Language; + } + } + + for (const value of values.reverse()) { + if (value.startsWith(short)) { + return value as Language; + } + } + + return Language.ENGLISH; } export function locale(state = findLanguage(), action: LocaleAction): Language { - switch (action.type) { - case "SET_LOCALE": - return action.locale; - case "SYNC_UPDATE": - return (action.update.locale?.[1] ?? state) as Language; - default: - return state; - } + switch (action.type) { + case "SET_LOCALE": + return action.locale; + case "SYNC_UPDATE": + return (action.update.locale?.[1] ?? state) as Language; + default: + return state; + } } diff --git a/src/redux/reducers/notifications.ts b/src/redux/reducers/notifications.ts index 6ffd352e190291e933c872698e68e90ceba1cee3..cb0f34dcaa6b45439b94f75bc53c59ce475b9c5b 100644 --- a/src/redux/reducers/notifications.ts +++ b/src/redux/reducers/notifications.ts @@ -1,72 +1,82 @@ import type { Channel, Message } from "revolt.js"; + import type { SyncUpdateAction } from "./sync"; -export type NotificationState = 'all' | 'mention' | 'none' | 'muted'; +export type NotificationState = "all" | "mention" | "none" | "muted"; export type Notifications = { - [key: string]: NotificationState -} + [key: string]: NotificationState; +}; -export const DEFAULT_STATES: { [key in Channel['channel_type']]: NotificationState } = { - 'SavedMessages': 'all', - 'DirectMessage': 'all', - 'Group': 'all', - 'TextChannel': 'mention', - 'VoiceChannel': 'mention' +export const DEFAULT_STATES: { + [key in Channel["channel_type"]]: NotificationState; +} = { + SavedMessages: "all", + DirectMessage: "all", + Group: "all", + TextChannel: "mention", + VoiceChannel: "mention", }; -export function getNotificationState(notifications: Notifications, channel: Channel) { - return notifications[channel._id] ?? DEFAULT_STATES[channel.channel_type]; +export function getNotificationState( + notifications: Notifications, + channel: Channel, +) { + return notifications[channel._id] ?? DEFAULT_STATES[channel.channel_type]; } -export function shouldNotify(state: NotificationState, message: Message, user_id: string) { - switch (state) { - case 'muted': - case 'none': return false; - case 'mention': { - if (!message.mentions?.includes(user_id)) return false; - } - } +export function shouldNotify( + state: NotificationState, + message: Message, + user_id: string, +) { + switch (state) { + case "muted": + case "none": + return false; + case "mention": { + if (!message.mentions?.includes(user_id)) return false; + } + } - return true; + return true; } export type NotificationsAction = - | { type: undefined } - | { - type: "NOTIFICATIONS_SET"; - key: string; - state: NotificationState; - } - | { - type: "NOTIFICATIONS_REMOVE"; - key: string; - } - | SyncUpdateAction - | { - type: "RESET"; - }; + | { type: undefined } + | { + type: "NOTIFICATIONS_SET"; + key: string; + state: NotificationState; + } + | { + type: "NOTIFICATIONS_REMOVE"; + key: string; + } + | SyncUpdateAction + | { + type: "RESET"; + }; export function notifications( - state = {} as Notifications, - action: NotificationsAction + state = {} as Notifications, + action: NotificationsAction, ): Notifications { - switch (action.type) { - case "NOTIFICATIONS_SET": - return { - ...state, - [action.key]: action.state - }; - case "NOTIFICATIONS_REMOVE": - { - const { [action.key]: _, ...newState } = state; - return newState; - } - case "SYNC_UPDATE": - return action.update.notifications?.[1] ?? state; - case "RESET": - return {}; - default: - return state; - } + switch (action.type) { + case "NOTIFICATIONS_SET": + return { + ...state, + [action.key]: action.state, + }; + case "NOTIFICATIONS_REMOVE": { + const { [action.key]: _, ...newState } = state; + return newState; + } + case "SYNC_UPDATE": + return action.update.notifications?.[1] ?? state; + case "RESET": + return {}; + default: + return state; + } } diff --git a/src/redux/reducers/queue.ts b/src/redux/reducers/queue.ts index ef64578edd0920622f538b94a7347bda19c212b4..20020e0ffdea55af43859f98307ac3c373220684 100644 --- a/src/redux/reducers/queue.ts +++ b/src/redux/reducers/queue.ts @@ -1,113 +1,113 @@ import type { MessageObject } from "../../context/revoltjs/util"; export enum QueueStatus { - SENDING = "sending", - ERRORED = "errored", + SENDING = "sending", + ERRORED = "errored", } export interface Reply { - id: string, - mention: boolean + id: string; + mention: boolean; } -export type QueuedMessageData = Omit<MessageObject, 'content' | 'replies'> & { - content: string; - replies: Reply[]; -} +export type QueuedMessageData = Omit<MessageObject, "content" | "replies"> & { + content: string; + replies: Reply[]; +}; export interface QueuedMessage { - id: string; - channel: string; - data: QueuedMessageData; - status: QueueStatus; - error?: string; + id: string; + channel: string; + data: QueuedMessageData; + status: QueueStatus; + error?: string; } export type QueueAction = - | { type: undefined } - | { - type: "QUEUE_ADD"; - nonce: string; - channel: string; - message: QueuedMessageData; - } - | { - type: "QUEUE_FAIL"; - nonce: string; - error: string; - } - | { - type: "QUEUE_START"; - nonce: string; - } - | { - type: "QUEUE_REMOVE"; - nonce: string; - } - | { - type: "QUEUE_DROP_ALL"; - } - | { - type: "QUEUE_FAIL_ALL"; - } - | { - type: "RESET"; - }; + | { type: undefined } + | { + type: "QUEUE_ADD"; + nonce: string; + channel: string; + message: QueuedMessageData; + } + | { + type: "QUEUE_FAIL"; + nonce: string; + error: string; + } + | { + type: "QUEUE_START"; + nonce: string; + } + | { + type: "QUEUE_REMOVE"; + nonce: string; + } + | { + type: "QUEUE_DROP_ALL"; + } + | { + type: "QUEUE_FAIL_ALL"; + } + | { + type: "RESET"; + }; export function queue( - state: QueuedMessage[] = [], - action: QueueAction + state: QueuedMessage[] = [], + action: QueueAction, ): QueuedMessage[] { - switch (action.type) { - case "QUEUE_ADD": { - return [ - ...state.filter((x) => x.id !== action.nonce), - { - id: action.nonce, - data: action.message, - channel: action.channel, - status: QueueStatus.SENDING, - }, - ]; - } - case "QUEUE_FAIL": { - const entry = state.find( - (x) => x.id === action.nonce - ) as QueuedMessage; - return [ - ...state.filter((x) => x.id !== action.nonce), - { - ...entry, - status: QueueStatus.ERRORED, - error: action.error, - }, - ]; - } - case "QUEUE_START": { - const entry = state.find( - (x) => x.id === action.nonce - ) as QueuedMessage; - return [ - ...state.filter((x) => x.id !== action.nonce), - { - ...entry, - status: QueueStatus.SENDING, - }, - ]; - } - case "QUEUE_REMOVE": - return state.filter((x) => x.id !== action.nonce); - case "QUEUE_FAIL_ALL": - return state.map((x) => { - return { - ...x, - status: QueueStatus.ERRORED, - }; - }); - case "QUEUE_DROP_ALL": - case "RESET": - return []; - default: - return state; - } + switch (action.type) { + case "QUEUE_ADD": { + return [ + ...state.filter((x) => x.id !== action.nonce), + { + id: action.nonce, + data: action.message, + channel: action.channel, + status: QueueStatus.SENDING, + }, + ]; + } + case "QUEUE_FAIL": { + const entry = state.find( + (x) => x.id === action.nonce, + ) as QueuedMessage; + return [ + ...state.filter((x) => x.id !== action.nonce), + { + ...entry, + status: QueueStatus.ERRORED, + error: action.error, + }, + ]; + } + case "QUEUE_START": { + const entry = state.find( + (x) => x.id === action.nonce, + ) as QueuedMessage; + return [ + ...state.filter((x) => x.id !== action.nonce), + { + ...entry, + status: QueueStatus.SENDING, + }, + ]; + } + case "QUEUE_REMOVE": + return state.filter((x) => x.id !== action.nonce); + case "QUEUE_FAIL_ALL": + return state.map((x) => { + return { + ...x, + status: QueueStatus.ERRORED, + }; + }); + case "QUEUE_DROP_ALL": + case "RESET": + return []; + default: + return state; + } } diff --git a/src/redux/reducers/section_toggle.ts b/src/redux/reducers/section_toggle.ts index 26b23dcac096f90985f5383bb7c64c18de4dfc49..68657fb37be17b7099b198a1a42b3d9fcae88a8f 100644 --- a/src/redux/reducers/section_toggle.ts +++ b/src/redux/reducers/section_toggle.ts @@ -1,37 +1,40 @@ export interface SectionToggle { - [key: string]: boolean + [key: string]: boolean; } export type SectionToggleAction = - | { type: undefined } - | { - type: "SECTION_TOGGLE_SET"; - id: string; - state: boolean; - } - | { - type: "SECTION_TOGGLE_UNSET"; - id: string; - } - | { - type: "RESET"; - }; + | { type: undefined } + | { + type: "SECTION_TOGGLE_SET"; + id: string; + state: boolean; + } + | { + type: "SECTION_TOGGLE_UNSET"; + id: string; + } + | { + type: "RESET"; + }; -export function sectionToggle(state = {} as SectionToggle, action: SectionToggleAction): SectionToggle { - switch (action.type) { - case "SECTION_TOGGLE_SET": { - return { - ...state, - [action.id]: action.state - } - } - case "SECTION_TOGGLE_UNSET": { - const { [action.id]: _, ...newState } = state; - return newState; - } - case "RESET": - return {}; - default: - return state; - } +export function sectionToggle( + state = {} as SectionToggle, + action: SectionToggleAction, +): SectionToggle { + switch (action.type) { + case "SECTION_TOGGLE_SET": { + return { + ...state, + [action.id]: action.state, + }; + } + case "SECTION_TOGGLE_UNSET": { + const { [action.id]: _, ...newState } = state; + return newState; + } + case "RESET": + return {}; + default: + return state; + } } diff --git a/src/redux/reducers/server_config.ts b/src/redux/reducers/server_config.ts index 6046c5d100c8c638b829c31f1e49494c600c82d7..a33ef1632d250ce2300077b3fd3b8b15cd99b8e4 100644 --- a/src/redux/reducers/server_config.ts +++ b/src/redux/reducers/server_config.ts @@ -1,20 +1,20 @@ import type { Core } from "revolt.js/dist/api/objects"; export type ConfigAction = - | { type: undefined } - | { - type: "SET_CONFIG"; - config: Core.RevoltNodeConfiguration; - }; + | { type: undefined } + | { + type: "SET_CONFIG"; + config: Core.RevoltNodeConfiguration; + }; export function config( - state = { } as Core.RevoltNodeConfiguration, - action: ConfigAction + state = {} as Core.RevoltNodeConfiguration, + action: ConfigAction, ): Core.RevoltNodeConfiguration { - switch (action.type) { - case "SET_CONFIG": - return action.config; - default: - return state; - } + switch (action.type) { + case "SET_CONFIG": + return action.config; + default: + return state; + } } diff --git a/src/redux/reducers/settings.ts b/src/redux/reducers/settings.ts index 35f5f737edbae93063ad9a3ef145c4403cfe4120..cf7dd124efae32d672f81f0a376cb62dc6c8ce4e 100644 --- a/src/redux/reducers/settings.ts +++ b/src/redux/reducers/settings.ts @@ -1,110 +1,112 @@ -import { filter } from "."; -import type { SyncUpdateAction } from "./sync"; -import type { Sounds } from "../../assets/sounds/Audio"; import type { Theme, ThemeOptions } from "../../context/Theme"; + import { setEmojiPack } from "../../components/common/Emoji"; +import { filter } from "."; +import type { Sounds } from "../../assets/sounds/Audio"; +import type { SyncUpdateAction } from "./sync"; + export type SoundOptions = { - [key in Sounds]?: boolean -} + [key in Sounds]?: boolean; +}; export const DEFAULT_SOUNDS: SoundOptions = { - message: true, - outbound: false, - call_join: true, - call_leave: true + message: true, + outbound: false, + call_join: true, + call_leave: true, }; export interface NotificationOptions { - desktopEnabled?: boolean; - sounds?: SoundOptions + desktopEnabled?: boolean; + sounds?: SoundOptions; } export type EmojiPacks = "mutant" | "twemoji" | "noto" | "openmoji"; export interface AppearanceOptions { - emojiPack?: EmojiPacks; + emojiPack?: EmojiPacks; } export interface Settings { - theme?: ThemeOptions; - appearance?: AppearanceOptions; - notification?: NotificationOptions; + theme?: ThemeOptions; + appearance?: AppearanceOptions; + notification?: NotificationOptions; } export type SettingsAction = - | { type: undefined } - | { - type: "SETTINGS_SET_THEME"; - theme: ThemeOptions; - } - | { - type: "SETTINGS_SET_THEME_OVERRIDE"; - custom?: Partial<Theme>; - } - | { - type: "SETTINGS_SET_NOTIFICATION_OPTIONS"; - options: NotificationOptions; - } - | { - type: "SETTINGS_SET_APPEARANCE"; - options: Partial<AppearanceOptions>; - } - | SyncUpdateAction - | { - type: "RESET"; - }; + | { type: undefined } + | { + type: "SETTINGS_SET_THEME"; + theme: ThemeOptions; + } + | { + type: "SETTINGS_SET_THEME_OVERRIDE"; + custom?: Partial<Theme>; + } + | { + type: "SETTINGS_SET_NOTIFICATION_OPTIONS"; + options: NotificationOptions; + } + | { + type: "SETTINGS_SET_APPEARANCE"; + options: Partial<AppearanceOptions>; + } + | SyncUpdateAction + | { + type: "RESET"; + }; export function settings( - state = {} as Settings, - action: SettingsAction + state = {} as Settings, + action: SettingsAction, ): Settings { - setEmojiPack(state.appearance?.emojiPack ?? 'mutant'); + setEmojiPack(state.appearance?.emojiPack ?? "mutant"); - switch (action.type) { - case "SETTINGS_SET_THEME": - return { - ...state, - theme: { - ...filter(state.theme, ["custom", "preset", "ligatures"]), - ...action.theme, - }, - }; - case "SETTINGS_SET_THEME_OVERRIDE": - return { - ...state, - theme: { - ...state.theme, - custom: { - ...state.theme?.custom, - ...action.custom, - }, - }, - }; - case "SETTINGS_SET_NOTIFICATION_OPTIONS": - return { - ...state, - notification: { - ...state.notification, - ...action.options, - }, - }; - case "SETTINGS_SET_APPEARANCE": - return { - ...state, - appearance: { - ...filter(state.appearance, ["emojiPack"]), - ...action.options, - }, - }; - case "SYNC_UPDATE": - return { - ...state, - appearance: action.update.appearance?.[1] ?? state.appearance, - theme: action.update.theme?.[1] ?? state.theme, - }; - case "RESET": - return {}; - default: - return state; - } + switch (action.type) { + case "SETTINGS_SET_THEME": + return { + ...state, + theme: { + ...filter(state.theme, ["custom", "preset", "ligatures"]), + ...action.theme, + }, + }; + case "SETTINGS_SET_THEME_OVERRIDE": + return { + ...state, + theme: { + ...state.theme, + custom: { + ...state.theme?.custom, + ...action.custom, + }, + }, + }; + case "SETTINGS_SET_NOTIFICATION_OPTIONS": + return { + ...state, + notification: { + ...state.notification, + ...action.options, + }, + }; + case "SETTINGS_SET_APPEARANCE": + return { + ...state, + appearance: { + ...filter(state.appearance, ["emojiPack"]), + ...action.options, + }, + }; + case "SYNC_UPDATE": + return { + ...state, + appearance: action.update.appearance?.[1] ?? state.appearance, + theme: action.update.theme?.[1] ?? state.theme, + }; + case "RESET": + return {}; + default: + return state; + } } diff --git a/src/redux/reducers/sync.ts b/src/redux/reducers/sync.ts index 099d98d6acccdf10843069820958ef2cc5bdd2e5..5e465ee48f8c32f566265475739d92a59878a390 100644 --- a/src/redux/reducers/sync.ts +++ b/src/redux/reducers/sync.ts @@ -1,93 +1,94 @@ -import type { AppearanceOptions } from "./settings"; import type { Language } from "../../context/Locale"; import type { ThemeOptions } from "../../context/Theme"; + import type { Notifications } from "./notifications"; +import type { AppearanceOptions } from "./settings"; export type SyncKeys = "theme" | "appearance" | "locale" | "notifications"; export interface SyncData { - locale?: Language; - theme?: ThemeOptions; - appearance?: AppearanceOptions; - notifications?: Notifications; + locale?: Language; + theme?: ThemeOptions; + appearance?: AppearanceOptions; + notifications?: Notifications; } export const DEFAULT_ENABLED_SYNC: SyncKeys[] = [ - "theme", - "appearance", - "locale", - "notifications" + "theme", + "appearance", + "locale", + "notifications", ]; export interface SyncOptions { - disabled?: SyncKeys[]; - revision?: { - [key: string]: number; - }; + disabled?: SyncKeys[]; + revision?: { + [key: string]: number; + }; } export type SyncUpdateAction = { - type: "SYNC_UPDATE"; - update: { [key in SyncKeys]?: [number, SyncData[key]] }; + type: "SYNC_UPDATE"; + update: { [key in SyncKeys]?: [number, SyncData[key]] }; }; export type SyncAction = - | { type: undefined } - | { - type: "SYNC_ENABLE_KEY"; - key: SyncKeys; - } - | { - type: "SYNC_DISABLE_KEY"; - key: SyncKeys; - } - | { - type: "SYNC_SET_REVISION"; - key: SyncKeys; - timestamp: number; - } - | SyncUpdateAction; + | { type: undefined } + | { + type: "SYNC_ENABLE_KEY"; + key: SyncKeys; + } + | { + type: "SYNC_DISABLE_KEY"; + key: SyncKeys; + } + | { + type: "SYNC_SET_REVISION"; + key: SyncKeys; + timestamp: number; + } + | SyncUpdateAction; export function sync( - state = {} as SyncOptions, - action: SyncAction + state = {} as SyncOptions, + action: SyncAction, ): SyncOptions { - switch (action.type) { - case "SYNC_DISABLE_KEY": - return { - ...state, - disabled: [ - ...(state.disabled ?? []).filter((v) => v !== action.key), - action.key, - ], - }; - case "SYNC_ENABLE_KEY": - return { - ...state, - disabled: state.disabled?.filter((v) => v !== action.key), - }; - case "SYNC_SET_REVISION": - return { - ...state, - revision: { - ...state.revision, - [action.key]: action.timestamp, - }, - }; - case "SYNC_UPDATE": { - const revision = { ...state.revision }; - for (const key of Object.keys(action.update)) { - const value = action.update[key as SyncKeys]; - if (value) { - revision[key] = value[0]; - } - } + switch (action.type) { + case "SYNC_DISABLE_KEY": + return { + ...state, + disabled: [ + ...(state.disabled ?? []).filter((v) => v !== action.key), + action.key, + ], + }; + case "SYNC_ENABLE_KEY": + return { + ...state, + disabled: state.disabled?.filter((v) => v !== action.key), + }; + case "SYNC_SET_REVISION": + return { + ...state, + revision: { + ...state.revision, + [action.key]: action.timestamp, + }, + }; + case "SYNC_UPDATE": { + const revision = { ...state.revision }; + for (const key of Object.keys(action.update)) { + const value = action.update[key as SyncKeys]; + if (value) { + revision[key] = value[0]; + } + } - return { - ...state, - revision, - }; - } - default: - return state; - } + return { + ...state, + revision, + }; + } + default: + return state; + } } diff --git a/src/redux/reducers/typing.ts b/src/redux/reducers/typing.ts index 7ec152aa18cb3663e17f9e96a3594d6b346fc699..9dcbc594cbf4f2ed0c8b28434416ad7c0b3890df 100644 --- a/src/redux/reducers/typing.ts +++ b/src/redux/reducers/typing.ts @@ -2,47 +2,47 @@ export type TypingUser = { id: string; started: number }; export type Typing = { [key: string]: TypingUser[] }; export type TypingAction = - | { type: undefined } - | { - type: "TYPING_START"; - channel: string; - user: string; - } - | { - type: "TYPING_STOP"; - channel: string; - user: string; - } - | { - type: "RESET"; - }; + | { type: undefined } + | { + type: "TYPING_START"; + channel: string; + user: string; + } + | { + type: "TYPING_STOP"; + channel: string; + user: string; + } + | { + type: "RESET"; + }; export function typing(state: Typing = {}, action: TypingAction): Typing { - switch (action.type) { - case "TYPING_START": - return { - ...state, - [action.channel]: [ - ...(state[action.channel] ?? []).filter( - (x) => x.id !== action.user - ), - { - id: action.user, - started: +new Date(), - }, - ], - }; - case "TYPING_STOP": - return { - ...state, - [action.channel]: - state[action.channel]?.filter( - (x) => x.id !== action.user - ) ?? [], - }; - case "RESET": - return {}; - default: - return state; - } + switch (action.type) { + case "TYPING_START": + return { + ...state, + [action.channel]: [ + ...(state[action.channel] ?? []).filter( + (x) => x.id !== action.user, + ), + { + id: action.user, + started: +new Date(), + }, + ], + }; + case "TYPING_STOP": + return { + ...state, + [action.channel]: + state[action.channel]?.filter( + (x) => x.id !== action.user, + ) ?? [], + }; + case "RESET": + return {}; + default: + return state; + } } diff --git a/src/redux/reducers/unreads.ts b/src/redux/reducers/unreads.ts index 1f1b2fb15e16da8610873dfccd14a2bef30db819..e9e1a9a7a40bbc1f9b7c73cbc38751de533d5b01 100644 --- a/src/redux/reducers/unreads.ts +++ b/src/redux/reducers/unreads.ts @@ -1,61 +1,61 @@ import type { Sync } from "revolt.js/dist/api/objects"; export interface Unreads { - [key: string]: Partial<Omit<Sync.ChannelUnread, "_id">>; + [key: string]: Partial<Omit<Sync.ChannelUnread, "_id">>; } export type UnreadsAction = - | { type: undefined } - | { - type: "UNREADS_MARK_READ"; - channel: string; - message: string; - } - | { - type: "UNREADS_SET"; - unreads: Sync.ChannelUnread[]; - } - | { - type: "UNREADS_MENTION"; - channel: string; - message: string; - } - | { - type: "RESET"; - }; + | { type: undefined } + | { + type: "UNREADS_MARK_READ"; + channel: string; + message: string; + } + | { + type: "UNREADS_SET"; + unreads: Sync.ChannelUnread[]; + } + | { + type: "UNREADS_MENTION"; + channel: string; + message: string; + } + | { + type: "RESET"; + }; export function unreads(state = {} as Unreads, action: UnreadsAction): Unreads { - switch (action.type) { - case "UNREADS_MARK_READ": - return { - ...state, - [action.channel]: { - last_id: action.message, - }, - }; - case "UNREADS_SET": { - const obj: Unreads = {}; - for (const entry of action.unreads) { - const { _id, ...v } = entry; - obj[_id.channel] = v; - } + switch (action.type) { + case "UNREADS_MARK_READ": + return { + ...state, + [action.channel]: { + last_id: action.message, + }, + }; + case "UNREADS_SET": { + const obj: Unreads = {}; + for (const entry of action.unreads) { + const { _id, ...v } = entry; + obj[_id.channel] = v; + } - return obj; - } - case "UNREADS_MENTION": { - const obj = state[action.channel]; + return obj; + } + case "UNREADS_MENTION": { + const obj = state[action.channel]; - return { - ...state, - [action.channel]: { - ...obj, - mentions: [...(obj?.mentions ?? []), action.message], - }, - }; - } - case "RESET": - return {}; - default: - return state; - } + return { + ...state, + [action.channel]: { + ...obj, + mentions: [...(obj?.mentions ?? []), action.message], + }, + }; + } + case "RESET": + return {}; + default: + return state; + } } diff --git a/src/revision.ts b/src/revision.ts index e01dcdda3515714a15fa6c6b1226800b29ad3f5b..6cb9d248887b1c1a16d18af1f2d631110ad3c910 100644 --- a/src/revision.ts +++ b/src/revision.ts @@ -1,3 +1,3 @@ -export const REPO_URL = 'https://gitlab.insrt.uk/revolt/revite/-/commit'; -export const GIT_REVISION = '__GIT_REVISION__'; -export const GIT_BRANCH: string = '__GIT_BRANCH__'; +export const REPO_URL = "https://gitlab.insrt.uk/revolt/revite/-/commit"; +export const GIT_REVISION = "__GIT_REVISION__"; +export const GIT_BRANCH: string = "__GIT_BRANCH__"; diff --git a/src/sw.ts b/src/sw.ts index 7d58aba381355dbda79f57bb166e0e0466dd05d0..bf237fa2230384c354cc858bebb94bf7f2641f3b 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,48 +1,51 @@ /// <reference lib="webworker" /> -import { precacheAndRoute } from 'workbox-precaching' -import { Server } from 'revolt.js/dist/api/objects' -import { Channel, Message, User } from 'revolt.js' -import { IDBPDatabase, openDB } from 'idb' -import { getItem } from 'localforage' -import type { State } from './redux' -import { getNotificationState, shouldNotify } from './redux/reducers/notifications' +import { IDBPDatabase, openDB } from "idb"; +import { getItem } from "localforage"; +import { Channel, Message, User } from "revolt.js"; +import { Server } from "revolt.js/dist/api/objects"; +import { precacheAndRoute } from "workbox-precaching"; -declare let self: ServiceWorkerGlobalScope +import type { State } from "./redux"; +import { + getNotificationState, + shouldNotify, +} from "./redux/reducers/notifications"; -self.addEventListener('message', (event) => { - if (event.data && event.data.type === 'SKIP_WAITING') - self.skipWaiting() -}) +declare let self: ServiceWorkerGlobalScope; -precacheAndRoute(self.__WB_MANIFEST) +self.addEventListener("message", (event) => { + if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting(); +}); + +precacheAndRoute(self.__WB_MANIFEST); // ulid decodeTime(id: string) // since crypto is not available in sw.js -const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" -const ENCODING_LEN = ENCODING.length -const TIME_LEN = 10 +const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +const ENCODING_LEN = ENCODING.length; +const TIME_LEN = 10; function decodeTime(id: string) { - var time = id.substr(0, TIME_LEN) + var time = id + .substr(0, TIME_LEN) .split("") .reverse() .reduce(function (carry, char, index) { var encodingIndex = ENCODING.indexOf(char); - if (encodingIndex === -1) - throw "invalid character found: " + char; - - return carry += encodingIndex * Math.pow(ENCODING_LEN, index); + if (encodingIndex === -1) throw "invalid character found: " + char; + + return (carry += encodingIndex * Math.pow(ENCODING_LEN, index)); }, 0); - - return time; + + return time; } -self.addEventListener("push", event => { +self.addEventListener("push", (event) => { async function process() { if (event.data === null) return; let data: Message = event.data.json(); - let item = await localStorage.getItem('state'); + let item = await localStorage.getItem("state"); if (!item) return; const state: State = JSON.parse(item); @@ -52,36 +55,46 @@ self.addEventListener("push", event => { let db: IDBPDatabase; try { // Match RevoltClient.tsx#L55 - db = await openDB('state', 3, { + db = await openDB("state", 3, { upgrade(db) { - for (let store of [ "channels", "servers", "users", "members" ]) { + for (let store of [ + "channels", + "servers", + "users", + "members", + ]) { db.createObjectStore(store, { - keyPath: '_id' + keyPath: "_id", }); } }, }); } catch (err) { - console.error('Failed to open IndexedDB store, continuing without.'); + console.error( + "Failed to open IndexedDB store, continuing without.", + ); return; } - async function get<T>(store: string, key: string): Promise<T | undefined> { + async function get<T>( + store: string, + key: string, + ): Promise<T | undefined> { try { return await db.get(store, key); } catch (err) { return undefined; } } - - let channel = await get<Channel>('channels', data.channel); - let user = await get<User>('users', data.author); + + let channel = await get<Channel>("channels", data.channel); + let user = await get<User>("users", data.author); if (channel) { const notifs = getNotificationState(state.notifications, channel); if (!shouldNotify(notifs, data, user_id)) return; } - + let title = `@${data.author}`; let username = user?.username ?? data.author; let image; @@ -91,12 +104,15 @@ self.addEventListener("push", event => { image = `${autumn_url}/${attachment.tag}/${attachment._id}`; } } - + switch (channel?.channel_type) { - case "SavedMessages": break; - case "DirectMessage": title = `@${username}`; break; + case "SavedMessages": + break; + case "DirectMessage": + title = `@${username}`; + break; case "Group": - if (user?._id === '00000000000000000000000000') { + if (user?._id === "00000000000000000000000000") { title = channel.name; } else { title = `@${user?.username} - ${channel.name}`; @@ -104,35 +120,43 @@ self.addEventListener("push", event => { break; case "TextChannel": { - let server = await get<Server>('servers', channel.server); + let server = await get<Server>("servers", channel.server); title = `@${user?.username} (#${channel.name}, ${server?.name})`; } break; } - + await self.registration.showNotification(title, { - icon: user?.avatar ? `${autumn_url}/${user.avatar.tag}/${user.avatar._id}` : `https://api.revolt.chat/users/${data.author}/default_avatar`, + icon: user?.avatar + ? `${autumn_url}/${user.avatar.tag}/${user.avatar._id}` + : `https://api.revolt.chat/users/${data.author}/default_avatar`, image, - body: typeof data.content === "string" ? data.content : JSON.stringify(data.content), + body: + typeof data.content === "string" + ? data.content + : JSON.stringify(data.content), timestamp: decodeTime(data._id), tag: data.channel, badge: "https://app.revolt.chat/assets/icons/android-chrome-512x512.png", - data: channel?.channel_type === 'TextChannel' ? `/server/${channel.server}/channel/${channel._id}` : `/channel/${data.channel}` + data: + channel?.channel_type === "TextChannel" + ? `/server/${channel.server}/channel/${channel._id}` + : `/channel/${data.channel}`, }); } - + event.waitUntil(process()); }); // ? Open the app on notification click. // https://stackoverflow.com/a/39457287 -self.addEventListener("notificationclick", function(event) { +self.addEventListener("notificationclick", function (event) { let url = event.notification.data; event.notification.close(); event.waitUntil( self.clients .matchAll({ includeUncontrolled: true, type: "window" }) - .then(windowClients => { + .then((windowClients) => { // Check if there is already a window/tab open with the target URL for (var i = 0; i < windowClients.length; i++) { var client = windowClients[i]; @@ -146,7 +170,6 @@ self.addEventListener("notificationclick", function(event) { if (self.clients.openWindow) { return self.clients.openWindow(url); } - }) + }), ); }); - \ No newline at end of file