diff --git a/.prettierrc.js b/.prettierrc.js index ac8ca3531a0457d3a6e853a8cf51830cab284daa..2d694ad6383fd47be7dd26c628ac67802676902e 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,6 +1,5 @@ module.exports = { "tabWidth": 4, - "useTabs": true, "trailingComma": "all", "jsxBracketSameLine": true, "importOrder": ["preact|classnames|.scss$", "/(lib)", "/(redux)", "/(context)", "/(ui|common)|.svg$", "^[./]"], diff --git a/src/assets/emojis.ts b/src/assets/emojis.ts index 1f58692783f403bcf266a97a2885bf9549d2b120..8c01f03f6df680e19cb26e74f4b57218434ea66a 100644 --- a/src/assets/emojis.ts +++ b/src/assets/emojis.ts @@ -1,1850 +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: "ðŸ´ó §ó ¢ó ·ó ¬ó ³ó ¿", + "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 be4881b3439ac821677c0ed8f0db2e426c2eb2a2..031e7b4f8dd2fb354a5e811bab214a6b4f1b6299 100644 --- a/src/assets/sounds/Audio.ts +++ b/src/assets/sounds/Audio.ts @@ -4,26 +4,26 @@ 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", + "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 419a969c66e17f42b360df4add3fb85131ff3c1d..ea978e123edfbe10e0794a7ff7590315a1d2a450 100644 --- a/src/components/common/AutoComplete.tsx +++ b/src/components/common/AutoComplete.tsx @@ -12,464 +12,464 @@ 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, + 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 [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, + 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> - ); + 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 e3dd136bba719bf9ddb49844dad7f2746be99f5a..9145d5f16c0e325d458ad5da245abb5462177a79 100644 --- a/src/components/common/ChannelIcon.tsx +++ b/src/components/common/ChannelIcon.tsx @@ -9,57 +9,57 @@ import { ImageIconBase, IconBaseProps } from "./IconBase"; import fallback from "./assets/group.png"; interface Props - extends IconBaseProps< - Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel - > { - isServerChannel?: boolean; + extends IconBaseProps< + Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel + > { + isServerChannel?: boolean; } export default function ChannelIcon( - props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>, + props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>, ) { - const client = useContext(AppContext); + 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")); + 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} />; - } - } - } + 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} - /> - ); + 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 3bd20db000806983ce48229bf07ab43e1fd23488..ac2d9809f5ac1b139cefdb40d3a6bfcd8326626c 100644 --- a/src/components/common/CollapsibleSection.tsx +++ b/src/components/common/CollapsibleSection.tsx @@ -8,52 +8,52 @@ import Details from "../ui/Details"; import { Children } from "../../types/Preact"; 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 + 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> - ); + 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 f27be2f5d223333de662c755ab020dc3360e28c2..8117d1d8da1f1243dd202b5129bd3d81d112cd55 100644 --- a/src/components/common/Emoji.tsx +++ b/src/components/common/Emoji.tsx @@ -4,29 +4,29 @@ 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; + 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); - } - } + 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; + return pairs; } // Taken from Twemoji source code. @@ -35,38 +35,38 @@ function codePoints(rune: string) { const UFE0Fg = /\uFE0F/g; 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, + size, }: { - emoji: string; - size?: number; + emoji: string; + size?: number; }) { - return ( - <img - alt={emoji} - className="emoji" - draggable={false} - src={parseEmoji(emoji)} - style={ - size ? { width: `${size}px`, height: `${size}px` } : undefined - } - /> - ); + 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 56640d90edcaa277fdef7c776a37f005a60f8254..305e5a3b8f1c80bc76fc600234ab37aee04082c8 100644 --- a/src/components/common/IconBase.tsx +++ b/src/components/common/IconBase.tsx @@ -2,40 +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 c214e6bf9a54b0914eae48c7c737aec0dae3ca2b..31c0de01b24b1c208bf90228e1f089c4863b7ce4 100644 --- a/src/components/common/LocaleSelector.tsx +++ b/src/components/common/LocaleSelector.tsx @@ -6,33 +6,33 @@ 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, - }; + return { + locale: state.locale, + }; }); diff --git a/src/components/common/ServerHeader.tsx b/src/components/common/ServerHeader.tsx index 46e6cd5bad610b1860d902e2dedaf6708611b43d..91cc0b39453c1d2441a15c4661a87fe3a4250130 100644 --- a/src/components/common/ServerHeader.tsx +++ b/src/components/common/ServerHeader.tsx @@ -10,40 +10,40 @@ 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 061a18d61ab0921860ebd394392f9aa674880603..5b9dad8d962cca7ee828a97309f7dd04dc53b2ba 100644 --- a/src/components/common/ServerIcon.tsx +++ b/src/components/common/ServerIcon.tsx @@ -8,61 +8,61 @@ 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: 0.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>, + props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>, ) { - const client = useContext(AppContext); + 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, - ); + 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 ?? ""; + 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 ( + <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} - /> - ); + 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 2dbfa24f76279068928c2cd9f90b0986541cff28..162253224164f1feb190138d49174fe7fd816f0f 100644 --- a/src/components/common/Tooltip.tsx +++ b/src/components/common/Tooltip.tsx @@ -6,55 +6,55 @@ import { Text } from "preact-i18n"; import { Children } from "../../types/Preact"; type Props = Omit<TippyProps, "children"> & { - children: Children; - content: 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 }, + 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} - /> - ); + const { permission, ...tooltipProps } = props; + + 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 d818c2ee075da6f0312ad28daa39f0eb14ec52a1..e7c8cd953ecae0acd87ab71ba56e4f8565fd11d7 100644 --- a/src/components/common/UpdateIndicator.tsx +++ b/src/components/common/UpdateIndicator.tsx @@ -14,18 +14,18 @@ var pendingUpdate = false; 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 1dda28d9a683babb4a5bd124471a6458a8da8b02..f2ca22cd9ba30917aa51eb88b0536d94f748798d 100644 --- a/src/components/common/messaging/Message.tsx +++ b/src/components/common/messaging/Message.tsx @@ -16,118 +16,118 @@ import Markdown from "../../markdown/Markdown"; import UserIcon from "../user/UserIcon"; import { Username } from "../user/UserShort"; import MessageBase, { - MessageContent, - MessageDetail, - MessageInfo, + MessageContent, + MessageDetail, + MessageInfo, } from "./MessageBase"; import Attachment from "./attachments/Attachment"; import { MessageReply } from "./attachments/MessageReply"; 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, + 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(); + // 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 ee9fa9466de8c37cc044471916fe2a18eea632af..afddb0d5d87150e4b43d9afa1609e7683143d39b 100644 --- a/src/components/common/messaging/MessageBase.tsx +++ b/src/components/common/messaging/MessageBase.tsx @@ -9,204 +9,204 @@ 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: 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; - `} + display: flex; + overflow-x: none; + padding: 0.125rem; + flex-direction: row; + padding-right: 16px; ${(props) => - props.mention && - css` - background: var(--mention); - `} + props.contrast && + css` + padding: 0.3rem; + border-radius: 4px; + background: var(--hover); + `} ${(props) => - props.blocked && - css` - filter: blur(4px); - transition: 0.2s ease filter; + props.head && + css` + margin-top: 12px; + `} - &:hover { - filter: none; - } - `} + ${(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.sending && + css` + opacity: 0.8; + color: var(--tertiary-foreground); + `} ${(props) => - props.failed && - css` - color: var(--error); - `} + 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: 0.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, + position, }: { - message: MessageObject; - position: "left" | "top"; + 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> - ); + 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 a1e78ad72006820d208a872bb434390e7a3027d9..fe323b1538e73ddc1e6a8aae3a96fc92c790b0c0 100644 --- a/src/components/common/messaging/MessageBox.tsx +++ b/src/components/common/messaging/MessageBox.tsx @@ -16,8 +16,8 @@ import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter"; import { useTranslation } from "../../../lib/i18n"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { - SingletonMessageRenderer, - SMOOTH_SCROLL_ON_RECEIVE, + SingletonMessageRenderer, + SMOOTH_SCROLL_ON_RECEIVE, } from "../../../lib/renderer/Singleton"; import { dispatch } from "../../../redux"; @@ -27,9 +27,9 @@ import { Reply } from "../../../redux/reducers/queue"; import { SoundContext } from "../../../context/Settings"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { - FileUploader, - grabFiles, - uploadFile, + FileUploader, + grabFiles, + uploadFile, } from "../../../context/revoltjs/FileUploads"; import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { useChannelPermission } from "../../../context/revoltjs/hooks"; @@ -43,454 +43,452 @@ import FilePreview from "./bars/FilePreview"; import ReplyBar from "./bars/ReplyBar"; 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: 0.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: 0.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, + 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 ecd78dc5938589488a681acb95d1a837089057bc..c08229249b504b6ef8ce1e9273a4961b55a3bd4c 100644 --- a/src/components/common/messaging/SystemMessage.tsx +++ b/src/components/common/messaging/SystemMessage.tsx @@ -12,149 +12,149 @@ import UserShort from "../user/UserShort"; import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase"; 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 4f88feb7aa693b790fdfdc14bc0d5d0d8af6abbe..a19eea42dc585a26caf9b01618b4f939d8409432 100644 --- a/src/components/common/messaging/attachments/Attachment.tsx +++ b/src/components/common/messaging/attachments/Attachment.tsx @@ -13,121 +13,121 @@ 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 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 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 b5458c9cf6e923c1c2e32e08159f52003a948386..c551d2bd0618a28631e6d41a3706f329480e2480 100644 --- a/src/components/common/messaging/attachments/AttachmentActions.tsx +++ b/src/components/common/messaging/attachments/AttachmentActions.tsx @@ -1,9 +1,9 @@ import { - Download, - LinkExternal, - File, - Headphone, - Video, + Download, + LinkExternal, + File, + Headphone, + Video, } from "@styled-icons/boxicons-regular"; import { Attachment } from "revolt.js/dist/api/objects"; @@ -18,98 +18,98 @@ 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"); - 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 4a28383df134568f1e4a46cd1c82ca4f20d1ccfb..0620b907dd56381641e58f1dd5f4d8b5c5f7176c 100644 --- a/src/components/common/messaging/attachments/MessageReply.tsx +++ b/src/components/common/messaging/attachments/MessageReply.tsx @@ -11,83 +11,83 @@ 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; + 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); + 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.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 a31d3d99d2a45c015c2511ccbcacbd9e32846425..f8ad00ef8cba6c11140a456ea74f835572cbf8ef 100644 --- a/src/components/common/messaging/attachments/TextFile.tsx +++ b/src/components/common/messaging/attachments/TextFile.tsx @@ -6,67 +6,67 @@ import { useContext, useEffect, useState } from "preact/hooks"; import RequiresOnline from "../../../../context/revoltjs/RequiresOnline"; import { - AppContext, - StatusContext, + 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 [content, setContent] = useState<undefined | string>(undefined); + const [loading, setLoading] = useState(false); + const status = useContext(StatusContext); + const client = useContext(AppContext); - const url = client.generateFileURL(attachment)!; + const url = client.generateFileURL(attachment)!; - useEffect(() => { - if (typeof content !== "undefined") return; - if (loading) return; - setLoading(true); + 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]); + 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> - ); + 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 92137d92f9619c437c7f99082d99495dba5ed8e7..e8cea2f3af295c352a4bb6e4b3bf06cdc59c0ff0 100644 --- a/src/components/common/messaging/bars/FilePreview.tsx +++ b/src/components/common/messaging/bars/FilePreview.tsx @@ -9,225 +9,225 @@ import { determineFileSize } from "../../../../lib/fileSize"; import { CAN_UPLOAD_AT_ONCE, UploadState } from "../MessageBox"; 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: 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; - } + 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); - } - } + 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, + remove, + index, }: { - file: File; - remove?: () => void; - index: number; + 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> - ); + 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 9accc90e88947004c4c29ecc281133c3bea6de02..0293fc192a41ce7daf4c017c945c4945ea476fe3 100644 --- a/src/components/common/messaging/bars/JumpToBottom.tsx +++ b/src/components/common/messaging/bars/JumpToBottom.tsx @@ -4,61 +4,61 @@ import styled from "styled-components"; import { Text } from "preact-i18n"; import { - SingletonMessageRenderer, - useRenderState, + SingletonMessageRenderer, + useRenderState, } from "../../../../lib/renderer/Singleton"; const Bar = styled.div` - z-index: 10; - position: relative; + 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 { + 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; - } + > div { + display: flex; + align-items: center; + gap: 6px; + } - &:hover { - color: var(--primary-text); - } + &:hover { + color: var(--primary-text); + } - &:active { - transform: translateY(1px); - } - } + &:active { + transform: translateY(1px); + } + } `; export default function JumpToBottom({ id }: { id: string }) { - const view = useRenderState(id); - if (!view || view.type !== "RENDER" || view.atBottom) return null; + 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> - ); + 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 5c4dbdada2452d6afc6ce2b0ac7c19e5d9429c19..4199c134b6896d7ff356124edb0e690e77d150f4 100644 --- a/src/components/common/messaging/bars/ReplyBar.tsx +++ b/src/components/common/messaging/bars/ReplyBar.tsx @@ -1,8 +1,8 @@ import { - At, - Reply as ReplyIcon, - File, - XCircle, + At, + Reply as ReplyIcon, + File, + XCircle, } from "@styled-icons/boxicons-regular"; import styled from "styled-components"; @@ -23,119 +23,119 @@ 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 a00e2ff9854a7d62b7bbb37a73993d0d25a3096c..e58a85b0e44d8e521f9d31089e1f93610a462236 100644 --- a/src/components/common/messaging/bars/TypingIndicator.tsx +++ b/src/components/common/messaging/bars/TypingIndicator.tsx @@ -11,111 +11,111 @@ 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 fa7a09fb8d772b6ba0f36f9e5732673d629a739a..7284b7cce0d4a7d65258d46a040ebe773cdc4184 100644 --- a/src/components/common/messaging/embed/Embed.tsx +++ b/src/components/common/messaging/embed/Embed.tsx @@ -10,7 +10,7 @@ import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/Me import EmbedMedia from "./EmbedMedia"; interface Props { - embed: EmbedRJS; + embed: EmbedRJS; } const MAX_EMBED_WIDTH = 480; @@ -19,149 +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 b97922d35d02eba7df29d21f7f692e138be6dbc5..73ab3e43a9132722666883634ff77b07dabeeca5 100644 --- a/src/components/common/messaging/embed/EmbedMedia.tsx +++ b/src/components/common/messaging/embed/EmbedMedia.tsx @@ -5,96 +5,96 @@ 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 1f7dd4c79eaf3809e8a76ba83d7deda7efa3c43e..bc8f41a7be3b4c77b4c827c8865b13b3d2509574 100644 --- a/src/components/common/messaging/embed/EmbedMediaActions.tsx +++ b/src/components/common/messaging/embed/EmbedMediaActions.tsx @@ -6,25 +6,25 @@ 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 7e204dd9919b1ad958a2520c573235e08d71073c..c125afd3832249973b1c9fd788cc555571060be8 100644 --- a/src/components/common/user/UserCheckbox.tsx +++ b/src/components/common/user/UserCheckbox.tsx @@ -7,10 +7,10 @@ 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 9783b3a3c02a6118a65fee47e5da6609f1671800..cd63f6cc9242a156f94c8b943ccd810d8ee6e1ee 100644 --- a/src/components/common/user/UserHeader.tsx +++ b/src/components/common/user/UserHeader.tsx @@ -19,66 +19,66 @@ 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; + gap: 0; + flex-grow: 1; + min-width: 0; + display: flex; + flex-direction: column; - * { - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } + * { + 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 d1c2fb9c3f95a5625e1e1ce1a2ac7a80f3b1387f..e5cd3dca2eb4a4e19bb5efdec80571dfda230a55 100644 --- a/src/components/common/user/UserIcon.tsx +++ b/src/components/common/user/UserIcon.tsx @@ -13,92 +13,92 @@ 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); + `} `; export default function UserIcon( - props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>, + props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>, ) { - const client = useContext(AppContext); + 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 3c2b4aa00e465ce6b8ae31b33edaed8a63463985..cd83a41a3d6acb61e34436cfa18d9e7df81c86b9 100644 --- a/src/components/common/user/UserShort.tsx +++ b/src/components/common/user/UserShort.tsx @@ -5,27 +5,27 @@ import { Text } from "preact-i18n"; import UserIcon from "./UserIcon"; export function Username({ - user, - ...otherProps + user, + ...otherProps }: { user?: User } & JSX.HTMLAttributes<HTMLElement>) { - return ( - <span {...otherProps}> - {user?.username ?? <Text id="app.main.channel.unknown_user" />} - </span> - ); + return ( + <span {...otherProps}> + {user?.username ?? <Text id="app.main.channel.unknown_user" />} + </span> + ); } export default function UserShort({ - user, - size, + user, + size, }: { - user?: User; - size?: number; + user?: User; + size?: number; }) { - return ( - <> - <UserIcon size={size ?? 24} target={user} /> - <Username user={user} /> - </> - ); + 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 d4b3f60cc3ef20a728b0e904a12c10d5bb45c116..c909b1e9ed51646d2755ea8ae369cc0116a8e5ef 100644 --- a/src/components/common/user/UserStatus.tsx +++ b/src/components/common/user/UserStatus.tsx @@ -4,29 +4,29 @@ 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 fbd274d087310380240300e2a371b49a9da32cb8..7e029b3610a5857ff820dcd2301d355b23779d21 100644 --- a/src/components/markdown/Markdown.tsx +++ b/src/components/markdown/Markdown.tsx @@ -3,15 +3,15 @@ import { Suspense, lazy } from "preact/compat"; 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 a1dafb90a1268db572bb74023ecc35fc28ff1c35..b501a6848173ac726934683d0ddeaed74239656a 100644 --- a/src/components/markdown/Renderer.tsx +++ b/src/components/markdown/Renderer.tsx @@ -26,167 +26,167 @@ 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); + 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); + 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 fd4629d4d6822b11135b8ad7f22a579967231e18..a34921d7845548ec953fc25bb866fa2173b6b897 100644 --- a/src/components/navigation/BottomNavigation.tsx +++ b/src/components/navigation/BottomNavigation.tsx @@ -13,84 +13,84 @@ 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, - }; + return { + lastOpened: state.lastOpened, + }; }); diff --git a/src/components/navigation/LeftSidebar.tsx b/src/components/navigation/LeftSidebar.tsx index ccc675d2e30461f683168273c87844bfabc36daf..4561e5a5ea9a8f4ef557be64834503fc315e3eea 100644 --- a/src/components/navigation/LeftSidebar.tsx +++ b/src/components/navigation/LeftSidebar.tsx @@ -6,27 +6,27 @@ import ServerListSidebar from "./left/ServerListSidebar"; import ServerSidebar from "./left/ServerSidebar"; 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 b1d6adfa826670321e12baeaccb899f57c7f4dd1..edfac4db2ebfa98cbb2baab5f7859fb0f4c14c58 100644 --- a/src/components/navigation/RightSidebar.tsx +++ b/src/components/navigation/RightSidebar.tsx @@ -4,16 +4,16 @@ 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 8d5cd607f5c746c524d4c55b61f070c0cde94d68..064c7b3e172dfb080b9db7e9802c82c3ccefd12f 100644 --- a/src/components/navigation/SidebarBase.tsx +++ b/src/components/navigation/SidebarBase.tsx @@ -3,36 +3,36 @@ 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 5da41cf5e9eb78868342cae83db08af87c50f4bb..4d841a239ffb89241b17368cda01db8dcff9ab8e 100644 --- a/src/components/navigation/items/ButtonItem.tsx +++ b/src/components/navigation/items/ButtonItem.tsx @@ -20,204 +20,204 @@ import IconButton from "../../ui/IconButton"; import { Children } from "../../../types/Preact"; type CommonProps = Omit< - JSX.HTMLAttributes<HTMLDivElement>, - "children" | "as" + JSX.HTMLAttributes<HTMLDivElement>, + "children" | "as" > & { - active?: boolean; - alert?: "unread" | "mention"; - alertCount?: number; + 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 667f2da5e7fd51c23d1a38f3f96805a05130b582..901ef62b0eb4a8373bd638e0def2b432ed98135c 100644 --- a/src/components/navigation/items/ConnectionStatus.tsx +++ b/src/components/navigation/items/ConnectionStatus.tsx @@ -2,39 +2,39 @@ import { Text } from "preact-i18n"; import { useContext } from "preact/hooks"; import { - ClientStatus, - StatusContext, + 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 a0de1380e4d5aec1a3671aab6308102c5434aae9..fe951e14ece743a6ffe8c8219b0f3df4fea02a8e 100644 --- a/src/components/navigation/left/HomeSidebar.tsx +++ b/src/components/navigation/left/HomeSidebar.tsx @@ -1,8 +1,8 @@ import { - Home, - UserDetail, - Wrench, - Notepad, + 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"; @@ -22,9 +22,9 @@ import { Unreads } from "../../../redux/reducers/unreads"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { - useDMs, - useForceUpdate, - useUsers, + useDMs, + useForceUpdate, + useUsers, } from "../../../context/revoltjs/hooks"; import UserHeader from "../../common/user/UserHeader"; @@ -37,157 +37,157 @@ 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 f75ac8e14f05ff565b091b0f94629a9c1fd74b9f..838b74c2d073cc6ae83f2432c0e6a5243d73bc72 100644 --- a/src/components/navigation/left/ServerListSidebar.tsx +++ b/src/components/navigation/left/ServerListSidebar.tsx @@ -15,10 +15,10 @@ import { Unreads } from "../../../redux/reducers/unreads"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { - useChannels, - useForceUpdate, - useSelf, - useServers, + useChannels, + useForceUpdate, + useSelf, + useServers, } from "../../../context/revoltjs/hooks"; import ServerIcon from "../../common/ServerIcon"; @@ -31,268 +31,268 @@ import { mapChannelWithUnread } from "./common"; import { Children } from "../../../types/Preact"; function Icon({ - children, - unread, - size, + children, + unread, + size, }: { - children: Children; - unread?: "mention" | "unread"; - size: number; + 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> - ); + 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; - `} + 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, - }; + return { + unreads: state.unreads, + lastOpened: state.lastOpened, + }; }); diff --git a/src/components/navigation/left/ServerSidebar.tsx b/src/components/navigation/left/ServerSidebar.tsx index 38bc1645c96b204feb51e3912f35438b8a24442f..82db3e2d18b213e7da114231d5a965a2571f92df 100644 --- a/src/components/navigation/left/ServerSidebar.tsx +++ b/src/components/navigation/left/ServerSidebar.tsx @@ -13,9 +13,9 @@ import { connectState } from "../../../redux/connector"; import { Unreads } from "../../../redux/reducers/unreads"; import { - useChannels, - useForceUpdate, - useServer, + useChannels, + useForceUpdate, + useServer, } from "../../../context/revoltjs/hooks"; import CollapsibleSection from "../../common/CollapsibleSection"; @@ -27,124 +27,124 @@ 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> - ); + 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, - }; + return { + unreads: state.unreads, + }; }); diff --git a/src/components/navigation/left/common.ts b/src/components/navigation/left/common.ts index 89168031ff38f0c853913e73b9e7f23877492bde..f8539c21f2d9a5fd9511d1953ddedcce25c7d27e 100644 --- a/src/components/navigation/left/common.ts +++ b/src/components/navigation/left/common.ts @@ -8,96 +8,96 @@ 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, + { channel, unreads }: UnreadProps, + context?: HookContext, ) { - const ctx = useForceUpdate(context); + 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 392f547ee13efec324ff5b3f7e37c4caee74d1db..17ac06d9c870a1c1cea4e6d33bf1518b209610b4 100644 --- a/src/components/navigation/right/ChannelDebugInfo.tsx +++ b/src/components/navigation/right/ChannelDebugInfo.tsx @@ -1,40 +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 a24fc9379ec6acfb6af52a0252eb2a5ce6a2b1d7..1381692b73fb6fa519ba98f27938e7b5adcf6114 100644 --- a/src/components/navigation/right/MemberSidebar.tsx +++ b/src/components/navigation/right/MemberSidebar.tsx @@ -8,15 +8,15 @@ import { useContext, useEffect, useState } from "preact/hooks"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { - AppContext, - ClientStatus, - StatusContext, + AppContext, + ClientStatus, + StatusContext, } from "../../../context/revoltjs/RevoltClient"; import { - HookContext, - useChannel, - useForceUpdate, - useUsers, + HookContext, + useChannel, + useForceUpdate, + useUsers, } from "../../../context/revoltjs/hooks"; import Category from "../../ui/Category"; @@ -28,35 +28,35 @@ 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); + 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; - } + 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, + 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 { 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[] = []; @@ -71,32 +71,32 @@ export function GroupMemberSidebar({ 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" @@ -121,146 +121,146 @@ export function GroupMemberSidebar({ )} </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, + 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); + 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]); + 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 - ), - ), - ); - } - } + // ! 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]); + 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; + // 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; - } + 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} /> - <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> - ); + 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 1ff99f8344f3127afa1c83bb217f7b3d4ba37565..4b96d20529087d7b5645239279575ad6f27fd83f 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 7604da39a277e754ca885b2f93edb89075dfab2a..870e7ed22a463a87be437f63f97039f21925c89d 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 80d117cfa1fcb45a4514faaafccd38fcda958af8..914d822ca333974ff6dd5598faf76bf3535ca3b5 100644 --- a/src/components/ui/Category.tsx +++ b/src/components/ui/Category.tsx @@ -4,52 +4,52 @@ import styled, { css } from "styled-components"; import { Children } from "../../types/Preact"; 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; - `} + 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" + JSX.HTMLAttributes<HTMLDivElement>, + "children" | "as" | "action" > & { - text: Children; - action?: () => void; - variant?: "default" | "uniform"; + 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 44d80114fff465e580bc6ff43e0b79eafc890689..26e531d573536adebcba56bfd92a94f5080ad425 100644 --- a/src/components/ui/Checkbox.tsx +++ b/src/components/ui/Checkbox.tsx @@ -4,105 +4,105 @@ 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: 0.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: 0.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 b0bd781ef1906a121f818a509e5258506a68455b..d9e0d66e7d14c944c87752d539d57e36c6fe2f74 100644 --- a/src/components/ui/ColourSwatches.tsx +++ b/src/components/ui/ColourSwatches.tsx @@ -5,123 +5,123 @@ 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 55911247d22fa407fa201c665a4e1267fc64ecfa..a02c1f4f63cc4825d19adc378661a12af57d3ce1 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: 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%; + 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 4f2a9dd5bca12b2f53eab4e5599e330926887055..4e32c867700c1450956e32743c0aac549bc806b3 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: 0.6875rem; - line-height: 0.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 58ed693514a0f87e696b6b88777287343883a6d2..5696f29a5234f4532161b72443caa4522c5fb75a 100644 --- a/src/components/ui/Details.tsx +++ b/src/components/ui/Details.tsx @@ -1,74 +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; - `} + 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: 0.2s opacity; + cursor: pointer; + list-style: none; + align-items: center; + transition: 0.2s opacity; - font-size: 12px; - font-weight: 600; - text-transform: uppercase; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; - &::marker, - &::-webkit-details-marker { - display: none; - } + &::marker, + &::-webkit-details-marker { + display: none; + } - .title { - flex-grow: 1; - margin-top: 1px; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } + .title { + flex-grow: 1; + margin-top: 1px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } - .padding { - display: flex; - align-items: center; + .padding { + display: flex; + align-items: center; - > svg { - flex-shrink: 0; - margin-inline-end: 4px; - transition: 0.2s ease transform; - } - } - } + > svg { + flex-shrink: 0; + margin-inline-end: 4px; + transition: 0.2s ease transform; + } + } + } - &:not([open]) { - summary { - opacity: 0.7; - } + &:not([open]) { + summary { + opacity: 0.7; + } - summary svg { - transform: rotateZ(-90deg); - } - } + summary svg { + transform: rotateZ(-90deg); + } + } `; diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx index e935b80a99adbcb0de8affbed6a255b546ca49eb..cbeaf59903390bca7f5d8bd7d21201d37c2cab45 100644 --- a/src/components/ui/Header.tsx +++ b/src/components/ui/Header.tsx @@ -1,57 +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; - } + @media (pointer: coarse) { + height: 56px; + } - ${(props) => - props.background && - css` - height: 120px !important; - align-items: flex-end; + ${(props) => + props.background && + css` + height: 120px !important; + align-items: flex-end; - text-shadow: 0px 0px 1px black; - `} + text-shadow: 0px 0px 1px black; + `} - ${(props) => - props.placement === "secondary" && - css` - background-color: var(--secondary-header); - padding: 14px; - `} + ${(props) => + props.placement === "secondary" && + css` + background-color: var(--secondary-header); + padding: 14px; + `} ${(props) => - props.borders && - css` - border-start-start-radius: 8px; - `} + props.borders && + css` + border-start-start-radius: 8px; + `} `; diff --git a/src/components/ui/IconButton.tsx b/src/components/ui/IconButton.tsx index 3044ec5a6c64ac1c817e282fe8f2bfed36c045c8..207332a78b37aeeda1717a0ce9a206a2ea75901c 100644 --- a/src/components/ui/IconButton.tsx +++ b/src/components/ui/IconButton.tsx @@ -1,46 +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: 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); - } - `} + 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 b6dcbb84bf0df256fa6eaae24a06fba521557cd6..a791edd7a92cb8f6be9a1925183e71769e8b2a0c 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 0ffd1e7c9532028b58181a06240d848a006649a3..58a9c7a45817feddd3bd9eb9aa9bc1b73dc2dd63 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 d8cadc71d08db113c242a345a0210137e846afb9..8b6939c85cf59a36aa289f9c3053a6c28bec202c 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> - ); + 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 57a43bf715581094d4a2ac25b934f180fae9da28..15f47e823c2c94a5f4b7f3f9f2005263caa2ec16 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -19,181 +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(0.3, 0.3, 0.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 } + { [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; - `} + 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.attachment && + css` + border-radius: 8px 8px 0 0; + `} ${(props) => - props.border && - css` - border-radius: 10px; - border: 2px solid var(--secondary-background); - `} + 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 8dc0b54e684036e9f02a4cce5cf057b148e56af2..cf30bbdbe3334a04038c1acf20bfd571d6b645a6 100644 --- a/src/components/ui/Overline.tsx +++ b/src/components/ui/Overline.tsx @@ -5,60 +5,60 @@ import { Text } from "preact-i18n"; import { Children } from "../../types/Preact"; type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, "children" | "as"> & { - error?: string; - block?: boolean; - spaced?: boolean; - children?: Children; - type?: "default" | "subtle" | "error"; + 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 a9e70ab4e1224fd3fb903e14d35b97aadd7f1729..00f3db36304c934ec1e135ecb75623762326e0aa 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 7bd220c488f8a6a879f6d01bcafbb4560ec65d7a..bf194cc096c8ce1a2e5113b7c773baa7b3b425b2 100644 --- a/src/components/ui/Radio.tsx +++ b/src/components/ui/Radio.tsx @@ -4,108 +4,108 @@ 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 3d3f296bfb769fe564a35a131070ab3f360946b6..ab087d5762c85703bc1c558f918149eccd3240a2 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,46 +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 0.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 0ef02c093b423acd984720203b285a3ea5c61cb3..053af1c59286215f7d7f69a53ddb5e44cd1f131f 100644 --- a/src/components/ui/Tip.tsx +++ b/src/components/ui/Tip.tsx @@ -4,66 +4,66 @@ 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 b9200e38f0bc162bfbdfadc33ee011d9d0764f67..1171b42d2ae60f41979390c6c2ac17a4fef1e578 100644 --- a/src/context/Locale.tsx +++ b/src/context/Locale.tsx @@ -16,202 +16,202 @@ 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 587f2a94f2c1b80d0e74ec2069b5ca11dbda81c3..78e268ccc41a278e7412dd72de0e3568669df276 100644 --- a/src/context/Settings.tsx +++ b/src/context/Settings.tsx @@ -13,9 +13,9 @@ import { useMemo } from "preact/hooks"; import { connectState } from "../redux/connector"; import { - DEFAULT_SOUNDS, - Settings, - SoundOptions, + DEFAULT_SOUNDS, + Settings, + SoundOptions, } from "../redux/reducers/settings"; import { playSound, Sounds } from "../assets/sounds/Audio"; @@ -25,37 +25,37 @@ export const SettingsContext = createContext<Settings>({}); export const SoundContext = createContext<(sound: Sounds) => void>(null!); interface Props { - children?: Children; - settings: Settings; + children?: Children; + settings: Settings; } 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> - ); + 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">>( - SettingsProvider, - (state) => { - return { - settings: state.settings, - }; - }, + SettingsProvider, + (state) => { + return { + settings: state.settings, + }; + }, ); diff --git a/src/context/Theme.tsx b/src/context/Theme.tsx index af0d218abfa1ad41acd84e3923add1647afbfec5..c947d85f34e48156e0d48337f2ef938f4ded09fb 100644 --- a/src/context/Theme.tsx +++ b/src/context/Theme.tsx @@ -11,209 +11,209 @@ import { connectState } from "../redux/connector"; import { Children } from "../types/Preact"; 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"; + | "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"; + | "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"); - }, - }, + "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 } + 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"), - }, + "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(); @@ -224,70 +224,70 @@ 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]};`; + })} } `; @@ -295,66 +295,66 @@ const GlobalTheme = createGlobalStyle<{ theme: Theme }>` 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, - }; + return { + options: state.settings.theme, + }; }); diff --git a/src/context/Voice.tsx b/src/context/Voice.tsx index b20f5824c790fa84bd82674551fca2476844112c..1869c0ac0a868e87209bc98d3784b94d640c7ecc 100644 --- a/src/context/Voice.tsx +++ b/src/context/Voice.tsx @@ -10,29 +10,29 @@ 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 @@ -40,168 +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 f8f94723f59b4317c76bf5caf73511ef7d7480c9..f7a3ccb6fcad229de89f6cc2c6df550fcb0d6a45 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -11,21 +11,21 @@ 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 8bbdfb1122dd04d891982ccf31c1031a3098777a..22b071153902ad021ed17105f0121285ed06681a 100644 --- a/src/context/intermediate/Intermediate.tsx +++ b/src/context/intermediate/Intermediate.tsx @@ -1,11 +1,11 @@ import { Prompt } from "react-router"; import { useHistory } from "react-router-dom"; import { - Attachment, - Channels, - EmbedImage, - Servers, - Users, + Attachment, + Channels, + EmbedImage, + Servers, + Users, } from "revolt.js/dist/api/objects"; import { createContext } from "preact"; @@ -19,161 +19,161 @@ import { Children } from "../../types/Preact"; 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 5ca0c164d7a45ffa99e5f7954cd4d6fe8bc214a8..4db686dd221dc245a04adb703384ce6e0e601629 100644 --- a/src/context/intermediate/Modals.tsx +++ b/src/context/intermediate/Modals.tsx @@ -7,27 +7,27 @@ import { PromptModal } from "./modals/Prompt"; import { SignedOutModal } from "./modals/SignedOut"; 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 f171f9f0851c428daa83ac5b2843e5c1e3ca5eb3..f2dc82cc8f54c9e05a2d6a0fab76b0e5610c2b1a 100644 --- a/src/context/intermediate/Popovers.tsx +++ b/src/context/intermediate/Popovers.tsx @@ -11,29 +11,29 @@ 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 8b238373ea0308cb8ff89abf2d0ae5e95dcf6a2f..d84c43d347da500c87091c51c10fee8fcfea596c 100644 --- a/src/context/intermediate/modals/Clipboard.tsx +++ b/src/context/intermediate/modals/Clipboard.tsx @@ -3,30 +3,30 @@ 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 81d115255ccaf77ead5f4eed211d26df342990a6..64cadebcddb7dc15c6fb780bd44aec48a0925639 100644 --- a/src/context/intermediate/modals/Error.tsx +++ b/src/context/intermediate/modals/Error.tsx @@ -3,28 +3,28 @@ 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 36e6026d4f1c35dbac3ea01e7db2129845d42099..9f2b37a71d6afa7a4c4c5e94cf5c519f0096128b 100644 --- a/src/context/intermediate/modals/Input.tsx +++ b/src/context/intermediate/modals/Input.tsx @@ -13,164 +13,164 @@ 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 35934bbe0991c02e464fc239fd548d7e8b1fe4ff..b5db4306e37bdfcad5e242d84aca8664b44202f0 100644 --- a/src/context/intermediate/modals/Onboarding.tsx +++ b/src/context/intermediate/modals/Onboarding.tsx @@ -12,67 +12,67 @@ 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 dd21a9ce0f0a00627313a33bf560ba6c4c5e6777..c09e1bb7408926c956f3448ae30c289f74006d01 100644 --- a/src/context/intermediate/modals/Prompt.tsx +++ b/src/context/intermediate/modals/Prompt.tsx @@ -21,453 +21,453 @@ import { mapMessage, takeError } from "../../revoltjs/util"; 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, + 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> - ); + 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"], - }; + 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; - } + 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); + 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 { - 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; - } + 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={ - <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); + 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); - try { - await client.channels.deleteMessage( - props.target.channel, - props.target._id, - ); + try { + await client.channels.deleteMessage( + props.target.channel, + props.target._id, + ); - 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={ + <> + <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(); - useEffect(() => { - setProcessing(true); + useEffect(() => { + setProcessing(true); - client.channels - .createInvite(props.target._id) - .then((code) => setCode(code)) - .catch((err) => setError(takeError(err))) - .finally(() => setProcessing(false)); - }, []); + client.channels + .createInvite(props.target._id) + .then((code) => setCode(code)) + .catch((err) => setError(takeError(err))) + .finally(() => setProcessing(false)); + }, []); - 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.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.kick_member`} />} - actions={[ - { - text: <Text id="app.special.modals.actions.kick" />, - 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.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.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.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(); + 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 e04ba1038c4e1d6f7c2650d3bd612e723fc2e202..7c7f357737279667fe58dedd07685101f9e1e325 100644 --- a/src/context/intermediate/modals/SignedOut.tsx +++ b/src/context/intermediate/modals/SignedOut.tsx @@ -3,22 +3,22 @@ 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 f10c14537589707627d3ae82eb2c94341b30aa39..18a492afc31a907c60bb8f9c5647a1e60a5b89c1 100644 --- a/src/context/intermediate/popovers/ChannelInfo.tsx +++ b/src/context/intermediate/popovers/ChannelInfo.tsx @@ -9,36 +9,36 @@ 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 a199ba70e11b6ceb1344568e64b821e7dac642a0..ca4f6baea2f39612229f1f0346f8df67a9cdd8be 100644 --- a/src/context/intermediate/popovers/ImageViewer.tsx +++ b/src/context/intermediate/popovers/ImageViewer.tsx @@ -10,37 +10,37 @@ 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); - } + // ! 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); + 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 b719554a39cc4d2f5d2835645edc99db4c129582..2b8aaf2bf36603f81af165034478bc0dd16827e6 100644 --- a/src/context/intermediate/popovers/ModifyAccount.tsx +++ b/src/context/intermediate/popovers/ModifyAccount.tsx @@ -11,124 +11,124 @@ 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 424e32683dfa86b4f52e2be9d787099e48cc007f..896958a13e8e6bba2281fdf2429e286302c752f2 100644 --- a/src/context/intermediate/popovers/PendingRequests.tsx +++ b/src/context/intermediate/popovers/PendingRequests.tsx @@ -7,25 +7,25 @@ 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 09f8e403e894db108921a9b2c11fc24bbd70cc75..0dc0792a2ef4fe790b9b23e51d130c6d63849e20 100644 --- a/src/context/intermediate/popovers/UserPicker.tsx +++ b/src/context/intermediate/popovers/UserPicker.tsx @@ -10,59 +10,59 @@ 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 beb2833359be5592b664a7888c64f8ffb158e9a4..653a6ee622590633deb4cdccf5b431587a8e7fa4 100644 --- a/src/context/intermediate/popovers/UserProfile.tsx +++ b/src/context/intermediate/popovers/UserProfile.tsx @@ -1,9 +1,9 @@ import { - Envelope, - Edit, - UserPlus, - Shield, - Money, + Envelope, + Edit, + UserPlus, + Shield, + Money, } from "@styled-icons/boxicons-regular"; import { Link, useHistory } from "react-router-dom"; import { Users } from "revolt.js/dist/api/objects"; @@ -25,338 +25,338 @@ import Preloader from "../../../components/ui/Preloader"; import Markdown from "../../../components/markdown/Markdown"; import { - AppContext, - ClientStatus, - StatusContext, + AppContext, + ClientStatus, + StatusContext, } from "../../revoltjs/RevoltClient"; import { - useChannels, - useForceUpdate, - useUserPermission, - useUsers, + 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 [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 history = useHistory(); + const client = useContext(AppContext); + const status = useContext(StatusContext); + const [tab, setTab] = useState("profile"); - const ctx = useForceUpdate(); - const all_users = useUsers(undefined, ctx); - const channels = useChannels(undefined, ctx); + 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; + 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 0865a786a7e735d96f571ec68eff6fd9fd52c423..943c2e43ce9b097f01111573761adecf3bd1cb59 100644 --- a/src/context/revoltjs/CheckAuth.tsx +++ b/src/context/revoltjs/CheckAuth.tsx @@ -6,18 +6,18 @@ 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 14b55f2e2b7ceeed454732386108032d00f99236..1aba0317e0363298bf121ca335ac046d1f2cc83e 100644 --- a/src/context/revoltjs/FileUploads.tsx +++ b/src/context/revoltjs/FileUploads.tsx @@ -17,276 +17,276 @@ 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; - } + | { 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; - } - ); + ( + | { + 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, + autumnURL: string, + tag: string, + file: File, + config?: AxiosRequestConfig, ) { - const formData = new FormData(); - formData.append("file", file); + const formData = new FormData(); + formData.append("file", file); - const res = await Axios.post(autumnURL + "/" + tag, formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - ...config, - }); + const res = await Axios.post(autumnURL + "/" + tag, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + ...config, + }); - return res.data.id; + return res.data.id; } export function grabFiles( - maxFileSize: number, - cb: (files: File[]) => void, - tooLarge: () => void, - multiple?: boolean, + 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(); - } - } - - cb(Array.from(files)); - }; - - input.click(); + 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(); + } + } + + cb(Array.from(files)); + }; + + 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 66e03059cacd44885eef9e0d27e839b3fba95859..40dddc5e6c7fcfc8942757d6fe481f957b523110 100644 --- a/src/context/revoltjs/Notifications.tsx +++ b/src/context/revoltjs/Notifications.tsx @@ -9,9 +9,9 @@ import { useTranslation } from "../../lib/i18n"; import { connectState } from "../../redux/connector"; import { - getNotificationState, - Notifications, - shouldNotify, + getNotificationState, + Notifications, + shouldNotify, } from "../../redux/reducers/notifications"; import { NotificationOptions } from "../../redux/reducers/settings"; @@ -19,268 +19,268 @@ 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, + title: string, + options: globalThis.NotificationOptions, ) { - try { - return new Notification(title, options); - } catch (err) { - let sw = await navigator.serviceWorker.getRegistration(); - sw?.showNotification(title, options); - } + 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 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); + 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; + 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 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; + const notifState = getNotificationState(notifs, channel); + if (!shouldNotify(notifState, msg, client.user!._id)) return; - playSound("message"); - if (!showNotification) 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 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 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 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, - }); + 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}`); - } - } - } - }); + 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], - ); - } - } + 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; + 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 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(), - }); + 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`); - }); - } + notif?.addEventListener("click", () => { + history.push(`/friends`); + }); + } - useEffect(() => { - client.addListener("message", message); - client.users.addListener("mutation", relationship); + 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]); + 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(); - } - } - } + useEffect(() => { + function visChange() { + if (document.visibilityState === "visible") { + if (notifications[channel_id]) { + notifications[channel_id].close(); + } + } + } - visChange(); + visChange(); - document.addEventListener("visibilitychange", visChange); - return () => - document.removeEventListener("visibilitychange", visChange); - }, [guild_id, channel_id]); + document.addEventListener("visibilitychange", visChange); + return () => + document.removeEventListener("visibilitychange", visChange); + }, [guild_id, channel_id]); - return null; + 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 NotificationsComponent() { - return ( - <Switch> - <Route path="/server/:server/channel/:channel"> - <NotifierComponent /> - </Route> - <Route path="/channel/:channel"> - <NotifierComponent /> - </Route> - <Route path="/"> - <NotifierComponent /> - </Route> - </Switch> - ); + 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 a68ce45901e4a01efd5e56705de5bc52daa62ae6..48e347c413e607827f65862ab08e002e86c0aa71 100644 --- a/src/context/revoltjs/RequiresOnline.tsx +++ b/src/context/revoltjs/RequiresOnline.tsx @@ -10,38 +10,38 @@ 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 ddf67a19c29283762cd19b1d3f3a8c0fe57473b1..6317e1c8e0f41239f045663d89fd3a2466064268 100644 --- a/src/context/revoltjs/RevoltClient.tsx +++ b/src/context/revoltjs/RevoltClient.tsx @@ -20,23 +20,23 @@ import { registerEvents, setReconnectDisallowed } from "./events"; 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. @@ -47,195 +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, - }; + return { + auth: state.auth, + sync: state.sync, + }; }); diff --git a/src/context/revoltjs/StateMonitor.tsx b/src/context/revoltjs/StateMonitor.tsx index f15d353aa3a548e379234428540e3f18ef5cac1a..85903cf245153a474ecb343f50bb6644e8959e0e 100644 --- a/src/context/revoltjs/StateMonitor.tsx +++ b/src/context/revoltjs/StateMonitor.tsx @@ -13,64 +13,64 @@ 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, - }; + return { + messages: [...state.queue], + typing: state.typing, + }; }); diff --git a/src/context/revoltjs/SyncManager.tsx b/src/context/revoltjs/SyncManager.tsx index ee24d525c1179634dd21dc146ad4be7b864419fc..86422d6dbe560b80997ea2f3caed81f6d9809400 100644 --- a/src/context/revoltjs/SyncManager.tsx +++ b/src/context/revoltjs/SyncManager.tsx @@ -12,135 +12,135 @@ import { connectState } from "../../redux/connector"; import { Notifications } from "../../redux/reducers/notifications"; import { Settings } from "../../redux/reducers/settings"; import { - DEFAULT_ENABLED_SYNC, - SyncData, - SyncKeys, - SyncOptions, + DEFAULT_ENABLED_SYNC, + SyncData, + SyncKeys, + SyncOptions, } from "../../redux/reducers/sync"; import { Language } from "../Locale"; import { AppContext, ClientStatus, StatusContext } from "./RevoltClient"; 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>, + 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; + 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, - }; + 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 b603f0876366eec12b132f34c96011319262ac38..30f80f959d4a8d824bbb945a8ad8303d7a7da8fc 100644 --- a/src/context/revoltjs/events.ts +++ b/src/context/revoltjs/events.ts @@ -11,145 +11,145 @@ 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, + { 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); - }; + 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 ffab5c9324a9f30f96a612980af8c8aee945fef0..2ceb97e26ae5948e30cbe874586c2b786629caad 100644 --- a/src/context/revoltjs/hooks.ts +++ b/src/context/revoltjs/hooks.ts @@ -7,226 +7,226 @@ 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] + 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 + keyof PickProperties<Client, Collection<any>>, + undefined >; function useObject( - type: ClientCollectionKey, - id?: string | string[], - context?: HookContext, + 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(); + 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 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]); + 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); + 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 ctx = useForceUpdate(context); - const mutation = (target: string) => target === id && ctx.forceUpdate(); - const mutationMember = (target: string) => - target.substr(26) === ctx.client.user!._id && ctx.forceUpdate(); + 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); + 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]); + 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); + 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 e575c71119220b27ff956e1379cc1f1f77a143d9..7d2665d859aaeec5207bd6b4139bbc085d60b163 100644 --- a/src/context/revoltjs/util.tsx +++ b/src/context/revoltjs/util.tsx @@ -6,52 +6,52 @@ import { Text } from "preact-i18n"; import { Children } from "../../types/Preact"; 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; + 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, + 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}</>; + 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 3b2063f055be1ba7b31517802335b778dc87a590..25be5fe2211a497c35e4861253605bbd18d6de31 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,4 +1,4 @@ interface ImportMetaEnv { - VITE_API_URL: string; - VITE_THEMES_URL: string; + VITE_API_URL: string; + VITE_THEMES_URL: string; } diff --git a/src/lib/ConditionalLink.tsx b/src/lib/ConditionalLink.tsx index 53f89551ff8a48b476e868a8e07c6386d6c5bbce..dd69d1ae5eb322b2fb013583cb00200bfd788c66 100644 --- a/src/lib/ConditionalLink.tsx +++ b/src/lib/ConditionalLink.tsx @@ -1,16 +1,16 @@ import { Link, LinkProps } from "react-router-dom"; type Props = LinkProps & - JSX.HTMLAttributes<HTMLAnchorElement> & { - active: boolean; - }; + 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 8f73e27141a834a37ac028eef80047e6b2598547..c0fac9f00700596995ba8143df7c99f121e575c4 100644 --- a/src/lib/ContextMenus.tsx +++ b/src/lib/ContextMenus.tsx @@ -1,35 +1,35 @@ import { - At, - Bell, - BellOff, - Check, - CheckSquare, - ChevronRight, - Block, - Square, - LeftArrowAlt, - Trash, + 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, + Attachment, + Channels, + Message, + Servers, + Users, } from "revolt.js/dist/api/objects"; import { - ChannelPermission, - ServerPermission, - UserPermission, + ChannelPermission, + ServerPermission, + UserPermission, } from "revolt.js/dist/api/permissions"; import { - ContextMenu, - ContextMenuWithData, - MenuItem, - openContextMenu, + ContextMenu, + ContextMenuWithData, + MenuItem, + openContextMenu, } from "preact-context-menu"; import { Text } from "preact-i18n"; import { useContext } from "preact/hooks"; @@ -37,26 +37,26 @@ import { useContext } from "preact/hooks"; import { dispatch } from "../redux"; import { connectState } from "../redux/connector"; import { - getNotificationState, - Notifications, - NotificationState, + getNotificationState, + Notifications, + NotificationState, } from "../redux/reducers/notifications"; import { QueuedMessage } from "../redux/reducers/queue"; import { useIntermediate } from "../context/intermediate/Intermediate"; import { - AppContext, - ClientStatus, - StatusContext, + AppContext, + ClientStatus, + StatusContext, } from "../context/revoltjs/RevoltClient"; import { - useChannel, - useChannelPermission, - useForceUpdate, - useServer, - useServerPermission, - useUser, - useUserPermission, + useChannel, + useChannelPermission, + useForceUpdate, + useServer, + useServerPermission, + useUser, + useUserPermission, } from "../context/revoltjs/hooks"; import { takeError } from "../context/revoltjs/util"; @@ -68,951 +68,951 @@ 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, - }; + return { + notifications: state.notifications, + }; }); diff --git a/src/lib/PaintCounter.tsx b/src/lib/PaintCounter.tsx index 15cd5c37c9d0b6324ca056e5f8404c803a3d4d3e..b346ec40da8bcaaae9a7c5c326a8d984426c479e 100644 --- a/src/lib/PaintCounter.tsx +++ b/src/lib/PaintCounter.tsx @@ -3,20 +3,20 @@ import { useState } from "preact/hooks"; const counts: { [key: string]: number } = {}; export default function PaintCounter({ - small, - always, + small, + always, }: { - small?: boolean; - always?: boolean; + small?: boolean; + always?: boolean; }) { - if (import.meta.env.PROD && !always) return null; + 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 a01cc5757b286e4f23d552bfe043cc5ad2151f3a..de9022b45356b7a7cc5d0af8e60ff1d05214f176 100644 --- a/src/lib/TextAreaAutoSize.tsx +++ b/src/lib/TextAreaAutoSize.tsx @@ -1,113 +1,113 @@ import { useEffect, useRef } from "preact/hooks"; import TextArea, { - DEFAULT_LINE_HEIGHT, - DEFAULT_TEXT_AREA_PADDING, - TextAreaProps, - TEXT_AREA_BORDER_WIDTH, + DEFAULT_LINE_HEIGHT, + DEFAULT_TEXT_AREA_PADDING, + TextAreaProps, + TEXT_AREA_BORDER_WIDTH, } from "../components/ui/TextArea"; import { internalSubscribe } from "./eventEmitter"; import { isTouchscreenDevice } from "./isTouchscreenDevice"; type TextAreaAutoSizeProps = Omit< - JSX.HTMLAttributes<HTMLTextAreaElement>, - "style" | "value" + JSX.HTMLAttributes<HTMLTextAreaElement>, + "style" | "value" > & - TextAreaProps & { - forceFocus?: boolean; - autoFocus?: boolean; - minHeight?: number; - maxRows?: number; - value: string; + 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 263dd5b119907fc81b7f633465e50add510cd062..92eeb120353177fd95883d15e85e9ed97e7ecf04 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 2b7d63ae3544a65c71ab8abdd227711187b91650..b65292bd4486ebac17ca8dfbd484652f68463849 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 131f1bf75098eed1571e4238bdaa2f4c3790a96c..e2881c8d64a5167de706e390c861249261fa6f76 100644 --- a/src/lib/eventEmitter.ts +++ b/src/lib/eventEmitter.ts @@ -3,16 +3,16 @@ import EventEmitter from "eventemitter3"; export const InternalEvent = new EventEmitter(); export function internalSubscribe( - ns: string, - event: string, - fn: (...args: any[]) => void, + ns: string, + event: string, + fn: (...args: any[]) => void, ) { - InternalEvent.addListener(ns + "/" + event, fn); - return () => InternalEvent.removeListener(ns + "/" + event, fn); + 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 9caea0cf734b758252bd615bdf53286b42e9041b..c6bba81d55577507c59070adb713ef186bf28acd 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 e8ca877773bd43774c91194e9bf3244bc5bf87ac..987f3faa5b39b1eda1d79ddfa7c0978d2ad2ed2c 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -4,59 +4,59 @@ 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 6f2dc0ab713b2009a202ce142b0c89cc568d1705..ee674992f1d64922198a75abee8af0da63cc8cb0 100644 --- a/src/lib/isTouchscreenDevice.ts +++ b/src/lib/isTouchscreenDevice.ts @@ -1,8 +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 fd71d194996f17a6159562f48afafbfbf17d3591..f95e8df894591db94a676883fd860d6efd94d86e 100644 --- a/src/lib/renderer/Singleton.ts +++ b/src/lib/renderer/Singleton.ts @@ -9,198 +9,198 @@ 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 ce4da1de32890e6fbc094ee61cf52295b6f8f879..05feadc8fbd1671a5643715038e3127b70341904 100644 --- a/src/lib/renderer/simple/SimpleRenderer.ts +++ b/src/lib/renderer/simple/SimpleRenderer.ts @@ -4,180 +4,180 @@ 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 29bb682a62f24dd97c0dbe43d3f35baa9e2ee69b..e0669250a109fbe16116761c6bc55d31f41d261c 100644 --- a/src/lib/renderer/types.ts +++ b/src/lib/renderer/types.ts @@ -5,44 +5,44 @@ 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>; + 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>; + 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 0a74bae8e7528ce56b76457dc622c1c24dfd4bbb..05043d25b417036f6b73debe217bdd5102903a12 100644 --- a/src/lib/stopPropagation.ts +++ b/src/lib/stopPropagation.ts @@ -1,8 +1,8 @@ export const stopPropagation = ( - ev: JSX.TargetedMouseEvent<HTMLDivElement>, - _consume?: any, + ev: JSX.TargetedMouseEvent<HTMLDivElement>, + _consume?: any, ) => { - ev.preventDefault(); - ev.stopPropagation(); - return true; + ev.preventDefault(); + ev.stopPropagation(); + return true; }; diff --git a/src/lib/vortex/Signaling.ts b/src/lib/vortex/Signaling.ts index 5def9042b186c5e26fb6aaf15737f09fe1b47514..916f9653ddb31b693c5164de55c79b02a5be4020 100644 --- a/src/lib/vortex/Signaling.ts +++ b/src/lib/vortex/Signaling.ts @@ -1,189 +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 5ab09a43e94ecbe4f5ef16bc0706c2b19086fe99..7cee61c7d2a1745e206a5b7e7b322c5428310d2a 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 9ebeab60ea7ecb8fdc642426d7b7810f01e17551..f0fede6f68a829d74418f49699be859c7ee4ef43 100644 --- a/src/lib/vortex/VoiceClient.ts +++ b/src/lib/vortex/VoiceClient.ts @@ -2,330 +2,330 @@ 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"; 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 770dcd6cfa64ad5da677383f5bbace0c6bcd50d4..842144d77c7004458844599b8617f9835f54b8c3 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 c842979523e9a97c0ee9f61aa631cc49f5a245d3..28081391f3b642ded419958cd123c9447b6d035f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,13 +8,13 @@ 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 - }, + 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 diff --git a/src/pages/Open.tsx b/src/pages/Open.tsx index 32a471c31b496a56fdee50247630e3d2d19ac06a..6a36e24605d9f9a099f44851578050bcdac7b63a 100644 --- a/src/pages/Open.tsx +++ b/src/pages/Open.tsx @@ -5,79 +5,79 @@ import { useContext, useEffect } from "preact/hooks"; import { useIntermediate } from "../context/intermediate/Intermediate"; import { - AppContext, - ClientStatus, - StatusContext, + AppContext, + ClientStatus, + StatusContext, } from "../context/revoltjs/RevoltClient"; import { - useChannels, - useForceUpdate, - useUser, + 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 da941deef896279370fd3fc8d7603a7b99eea3ff..d5137d88b0780d9565485cdf491581d9aa4299f5 100644 --- a/src/pages/RevoltApp.tsx +++ b/src/pages/RevoltApp.tsx @@ -24,97 +24,97 @@ 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 d5a23a47d42446df9f33c3a0f123c8627a02dea4..57badde4efa7f4d235d59ad187a247c16942f0fc 100644 --- a/src/pages/app.tsx +++ b/src/pages/app.tsx @@ -12,25 +12,25 @@ 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 0b4c9129e8f8dfb645d14fc30bff527d65ba3a2d..7b459ed515daa329c5f879e2a8577ff5fa8bf244 100644 --- a/src/pages/channels/Channel.tsx +++ b/src/pages/channels/Channel.tsx @@ -20,134 +20,134 @@ 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: 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> - </> - ); + 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: Channels.Channel }) { - return ( - <> - <ChannelHeader channel={channel} /> - <VoiceHeader id={channel._id} /> - </> - ); + return ( + <> + <ChannelHeader channel={channel} /> + <VoiceHeader id={channel._id} /> + </> + ); } export default function () { - const { channel } = useParams<{ channel: string }>(); - return <Channel id={channel} key={channel} />; + 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 7a0736948c6dd3ee19cbda85a354ed25863abfe0..e26a472a943bafd7bbef2599bbf97901afad6087 100644 --- a/src/pages/channels/ChannelHeader.tsx +++ b/src/pages/channels/ChannelHeader.tsx @@ -19,121 +19,121 @@ 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, + channel, + toggleSidebar, }: ChannelHeaderProps) { - const { openScreen } = useIntermediate(); - const client = useContext(AppContext); + 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 096835db5088bf0757035fb523a7c38d97aa31ee..dcb2ed0afb049b6aac84bf6b10ddcbe09f9e39f4 100644 --- a/src/pages/channels/actions/HeaderActions.tsx +++ b/src/pages/channels/actions/HeaderActions.tsx @@ -1,9 +1,9 @@ import { Sidebar as SidebarIcon } from "@styled-icons/boxicons-regular"; import { - UserPlus, - Cog, - PhoneCall, - PhoneOutgoing, + UserPlus, + Cog, + PhoneCall, + PhoneOutgoing, } from "@styled-icons/boxicons-solid"; import { useHistory } from "react-router-dom"; @@ -12,9 +12,9 @@ import { useContext } from "preact/hooks"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { - VoiceContext, - VoiceOperationsContext, - VoiceStatus, + VoiceContext, + VoiceOperationsContext, + VoiceStatus, } from "../../../context/Voice"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { AppContext } from "../../../context/revoltjs/RevoltClient"; @@ -25,88 +25,88 @@ import IconButton from "../../../components/ui/IconButton"; import { ChannelHeaderProps } from "../ChannelHeader"; export default function HeaderActions({ - channel, - toggleSidebar, + channel, + toggleSidebar, }: ChannelHeaderProps) { - const { openScreen } = useIntermediate(); - const client = useContext(AppContext); - const history = useHistory(); + 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; + 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 9fe82d7004a787d7fd8edafee083716b462e0be2..acaef3c7a7a405a8f7b6064ddc1bd6be28020ca4 100644 --- a/src/pages/channels/messaging/ConversationStart.tsx +++ b/src/pages/channels/messaging/ConversationStart.tsx @@ -6,35 +6,35 @@ 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 7fe720d15191a24857a637c586c3e939287b1326..1b0ad40717d16d79468645c9710dc54e5b1ccab6 100644 --- a/src/pages/channels/messaging/MessageArea.tsx +++ b/src/pages/channels/messaging/MessageArea.tsx @@ -4,11 +4,11 @@ import useResizeObserver from "use-resize-observer"; import { createContext } from "preact"; import { - useContext, - useEffect, - useLayoutEffect, - useRef, - useState, + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, } from "preact/hooks"; import { defer } from "../../../lib/defer"; @@ -19,8 +19,8 @@ import { RenderState, ScrollState } from "../../../lib/renderer/types"; import { IntermediateContext } from "../../../context/intermediate/Intermediate"; import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; import { - ClientStatus, - StatusContext, + ClientStatus, + StatusContext, } from "../../../context/revoltjs/RevoltClient"; import Preloader from "../../../components/ui/Preloader"; @@ -29,231 +29,231 @@ 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 78fe70bb4ef7752d6dca89983d671042207076cf..3aa1a43c3ca50252fdde210d1da1ee9de103af83 100644 --- a/src/pages/channels/messaging/MessageEditor.tsx +++ b/src/pages/channels/messaging/MessageEditor.tsx @@ -6,128 +6,128 @@ import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { - IntermediateContext, - useIntermediate, + IntermediateContext, + useIntermediate, } from "../../../context/intermediate/Intermediate"; import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { MessageObject } from "../../../context/revoltjs/util"; import AutoComplete, { - useAutoComplete, + useAutoComplete, } from "../../../components/common/AutoComplete"; const EditorBase = styled.div` - 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; - } - } - } + 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) { - 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> - ); + 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 1a9ae45a21cc52e3a4b4fc51aede9a8dee840e02..91856852aa906a993926029f080a0c8bb9dc468b 100644 --- a/src/pages/channels/messaging/MessageRenderer.tsx +++ b/src/pages/channels/messaging/MessageRenderer.tsx @@ -26,190 +26,190 @@ 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, - }; - }), + 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 106e27d8b1b5bae8f28882a4b3d638e96d120cdd..016391ed641fb778198c9d5d67bf8b39e693f801 100644 --- a/src/pages/channels/voice/VoiceHeader.tsx +++ b/src/pages/channels/voice/VoiceHeader.tsx @@ -5,134 +5,134 @@ import { Text } from "preact-i18n"; import { useContext } from "preact/hooks"; import { - VoiceContext, - VoiceOperationsContext, - VoiceStatus, + VoiceContext, + VoiceOperationsContext, + VoiceStatus, } from "../../../context/Voice"; import { - useForceUpdate, - useSelf, - useUsers, + useForceUpdate, + useSelf, + useUsers, } from "../../../context/revoltjs/hooks"; import UserIcon from "../../../components/common/user/UserIcon"; 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 6e39a16ad6be51d4d466d1cc9e12879fde754312..4b561d0323315e506fa3b489a1c7807e3c272416 100644 --- a/src/pages/developer/Developer.tsx +++ b/src/pages/developer/Developer.tsx @@ -11,31 +11,31 @@ import { useUserPermission } from "../../context/revoltjs/hooks"; 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 /> @@ -48,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 2632dcca64e0680917b0c3a79112b17ad11b24b8..650ce70e945ccca89acb324f812b630ba5572408 100644 --- a/src/pages/friends/Friend.tsx +++ b/src/pages/friends/Friend.tsx @@ -13,8 +13,8 @@ import { stopPropagation } from "../../lib/stopPropagation"; import { VoiceOperationsContext } from "../../context/Voice"; import { useIntermediate } from "../../context/intermediate/Intermediate"; import { - AppContext, - OperationsContext, + AppContext, + OperationsContext, } from "../../context/revoltjs/RevoltClient"; import UserIcon from "../../components/common/user/UserIcon"; @@ -24,113 +24,113 @@ 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 6b322e5b108dcd3e8b395fa7aee5f9a90dd921d6..d87170ea074721ac7f7e7fb9b17e867b214d462d 100644 --- a/src/pages/friends/Friends.tsx +++ b/src/pages/friends/Friends.tsx @@ -1,7 +1,7 @@ import { - ChevronDown, - ChevronRight, - ListPlus, + 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"; @@ -27,91 +27,91 @@ 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> @@ -119,98 +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 50f3c4941fbfb2efd81a2b94531be8b65f4ee6e8..a817fd316f3cf03c3a22fb6f795ce1c8483197d8 100644 --- a/src/pages/home/Home.tsx +++ b/src/pages/home/Home.tsx @@ -8,34 +8,34 @@ import wideSVG from "../../assets/wide.svg"; import Header from "../../components/ui/Header"; 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 eae15f08290f38e71b401d6cc00ea91687f3a22e..16b89bfd54a48c84b66dcaa8e2e95b3d20f21820 100644 --- a/src/pages/invite/Invite.tsx +++ b/src/pages/invite/Invite.tsx @@ -7,9 +7,9 @@ import { useContext, useEffect, useState } from "preact/hooks"; import RequiresOnline from "../../context/revoltjs/RequiresOnline"; import { - AppContext, - ClientStatus, - StatusContext, + AppContext, + ClientStatus, + StatusContext, } from "../../context/revoltjs/RevoltClient"; import { takeError } from "../../context/revoltjs/util"; @@ -20,109 +20,109 @@ 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 059e6be8249c385d44435c7cf61ef9803fa8f818..54b0d38a81fbd87b9600e3a5dfd51afb8200647b 100644 --- a/src/pages/login/FormField.tsx +++ b/src/pages/login/FormField.tsx @@ -4,68 +4,68 @@ 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 ffc2e2cd5da5c1e59d7e15ebc7da84cae6d51694..9e3d6f44f62c90750f4598a2bd3d839b26cad3f9 100644 --- a/src/pages/login/Login.tsx +++ b/src/pages/login/Login.tsx @@ -19,56 +19,56 @@ 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 9cca755ef72ae71f17b0d032e285aa75f2c41831..af62e0161ae8811909e3744092f4bf8b27d69f1e 100644 --- a/src/pages/login/forms/CaptchaBlock.tsx +++ b/src/pages/login/forms/CaptchaBlock.tsx @@ -9,33 +9,33 @@ 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 4caf7b9eee43fdce629f269a068a6e41e16028a2..8cfb8b0f8743b3de1fb88a2d6ae35362ca61ce40 100644 --- a/src/pages/login/forms/Form.tsx +++ b/src/pages/login/forms/Form.tsx @@ -20,232 +20,232 @@ 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 2ea6e323eacd7e0f6dd19a5a5dda8a231ac4b3b3..c141532672706bf23faf2e291fb35c3692ca7c22 100644 --- a/src/pages/login/forms/FormCreate.tsx +++ b/src/pages/login/forms/FormCreate.tsx @@ -5,14 +5,14 @@ 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 b7ea421ad0f6e216987da266a261faf0a626c3d2..a2c75d623cdcd9bcf41f98c62bfa260806170e59 100644 --- a/src/pages/login/forms/FormLogin.tsx +++ b/src/pages/login/forms/FormLogin.tsx @@ -8,25 +8,25 @@ 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 da1e27e5f521fc375a7bbb5e1e61bbe0ee3627ed..bc2cefb47afbedc72161bb0704dc1547788dc39a 100644 --- a/src/pages/login/forms/FormResend.tsx +++ b/src/pages/login/forms/FormResend.tsx @@ -5,14 +5,14 @@ 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 8e265688f443ba687d0e642575d8fddfc89a6af0..28b49e027c2b270ca88c6592375504a2bba2d023 100644 --- a/src/pages/login/forms/FormReset.tsx +++ b/src/pages/login/forms/FormReset.tsx @@ -7,33 +7,33 @@ 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 754429b8f7f41777ff65ccb932acb823bdefc7e5..1b1a1cbfb58220b9b5c3f0e0d4bb0f9d550dfa36 100644 --- a/src/pages/login/forms/Legal.tsx +++ b/src/pages/login/forms/Legal.tsx @@ -2,19 +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 8475f03c4a84cd4b5eab5c8a6db7d29255ee680c..ffb71b45d7e408baf62eb627bf04257a231544cf 100644 --- a/src/pages/login/forms/MailProvider.tsx +++ b/src/pages/login/forms/MailProvider.tsx @@ -4,53 +4,53 @@ 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 6720af598aee77cb8552bf5bffa758f1b43c8c5c..4e12183d7c240e78e1c57db435047fc455009bbe 100644 --- a/src/pages/settings/ChannelSettings.tsx +++ b/src/pages/settings/ChannelSettings.tsx @@ -13,75 +13,75 @@ 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 { 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`; - } + 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); - } - } + 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>, + 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 - /> - ); + <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 2f64ecc4d86790a165a160aac4885316ddc7a937..baea15ac31ee1aa47bf7f72a37eab682f6f6a65c 100644 --- a/src/pages/settings/GenericSettings.tsx +++ b/src/pages/settings/GenericSettings.tsx @@ -19,140 +19,140 @@ import ButtonItem from "../../components/navigation/items/ButtonItem"; 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, + pages, + switchPage, + category, + custom, + children, + defaultPage, + showExitButton, }: Props) { - const history = useHistory(); - const theme = useContext(ThemeContext); - const { page } = useParams<{ page: string }>(); + 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 bb0cde830583f8f41d25d7ae3081bce59451a6ad..e3b2a2e1a51985511853dc6941eb0e46690ebb95 100644 --- a/src/pages/settings/ServerSettings.tsx +++ b/src/pages/settings/ServerSettings.tsx @@ -1,8 +1,8 @@ import { - ListUl, - Share, - Group, - ListCheck, + ListUl, + Share, + Group, + ListCheck, } from "@styled-icons/boxicons-regular"; import { XSquare } from "@styled-icons/boxicons-solid"; import { Route, useHistory, useParams } from "react-router-dom"; @@ -22,85 +22,85 @@ 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 fba14e3b1567973bd10508543cb7d582cc14a9d2..bb23da490c43f8ed731c7ec6b2d344cd2c495a93 100644 --- a/src/pages/settings/Settings.tsx +++ b/src/pages/settings/Settings.tsx @@ -1,18 +1,18 @@ import { Gitlab } from "@styled-icons/boxicons-logos"; import { - Sync as SyncIcon, - Globe, - LogOut, + Sync as SyncIcon, + Globe, + LogOut, } from "@styled-icons/boxicons-regular"; import { - Bell, - Palette, - Coffee, - IdCard, - CheckShield, - Flask, - User, - Megaphone, + 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"; @@ -23,8 +23,8 @@ import { useContext } from "preact/hooks"; import RequiresOnline from "../../context/revoltjs/RequiresOnline"; import { - AppContext, - OperationsContext, + AppContext, + OperationsContext, } from "../../context/revoltjs/RevoltClient"; import LineDivider from "../../components/ui/LineDivider"; @@ -44,159 +44,159 @@ 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); + 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`); - } - } + 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 6df4e5a8a389bbca340476e6cec376d6c1a871e2..b8fbcdfa22e1cb623c20b5d47494b0d1d026f836 100644 --- a/src/pages/settings/channel/Overview.tsx +++ b/src/pages/settings/channel/Overview.tsx @@ -13,105 +13,105 @@ 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 c5467215631b7616279cbdf186e9a75c7e7c3027..b4d30834633b73fa05d8261bea97d691efed0e35 100644 --- a/src/pages/settings/channel/Permissions.tsx +++ b/src/pages/settings/channel/Permissions.tsx @@ -12,100 +12,100 @@ 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; + 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 08beb2e71b4bed0079d11607b4ae717e5b4ad548..3ebd443009721b09cea87d28bae61bc8d2f26150 100644 --- a/src/pages/settings/panes/Account.tsx +++ b/src/pages/settings/panes/Account.tsx @@ -9,8 +9,8 @@ import { useContext, useEffect, useState } from "preact/hooks"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { - ClientStatus, - StatusContext, + ClientStatus, + StatusContext, } from "../../../context/revoltjs/RevoltClient"; import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks"; @@ -20,87 +20,87 @@ 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 337e7451b426aa10244f12e069b0d9efff4a0925..7e16d1c4559244a9d276c4c998cfea538d587607 100644 --- a/src/pages/settings/panes/Appearance.tsx +++ b/src/pages/settings/panes/Appearance.tsx @@ -13,15 +13,15 @@ import { connectState } from "../../../redux/connector"; import { EmojiPacks, Settings } from "../../../redux/reducers/settings"; import { - DEFAULT_FONT, - DEFAULT_MONO_FONT, - FONTS, - FONT_KEYS, - MONOSCAPE_FONTS, - MONOSCAPE_FONT_KEYS, - Theme, - ThemeContext, - ThemeOptions, + 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"; @@ -39,92 +39,92 @@ 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}> @@ -146,229 +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, - }; + return { + settings: state.settings, + }; }); diff --git a/src/pages/settings/panes/Experiments.tsx b/src/pages/settings/panes/Experiments.tsx index 1ecf8c8e4222d10353523b9e097443ee71feecdb..3fbfdc48c5601325dc4cc4b9ab1d1e0b284b12cd 100644 --- a/src/pages/settings/panes/Experiments.tsx +++ b/src/pages/settings/panes/Experiments.tsx @@ -4,52 +4,52 @@ import { Text } from "preact-i18n"; import { dispatch } from "../../../redux"; import { connectState } from "../../../redux/connector"; import { - AVAILABLE_EXPERIMENTS, - ExperimentOptions, + 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, - }; + return { + options: state.experiments, + }; }); diff --git a/src/pages/settings/panes/Feedback.tsx b/src/pages/settings/panes/Feedback.tsx index fe86dd0c423d80cb42f7f26ee67391d0ce5933b6..e67ab4be1a5fe8d48e52c5a9f937722d2bfedc00 100644 --- a/src/pages/settings/panes/Feedback.tsx +++ b/src/pages/settings/panes/Feedback.tsx @@ -10,101 +10,101 @@ import Radio from "../../../components/ui/Radio"; import TextArea from "../../../components/ui/TextArea"; 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 772795c6b7db4c3cc36335902850fb7f3da27be4..559ccc0e8340275a4244fe03ab75be52db85b2b5 100644 --- a/src/pages/settings/panes/Languages.tsx +++ b/src/pages/settings/panes/Languages.tsx @@ -5,9 +5,9 @@ import { dispatch } from "../../../redux"; import { connectState } from "../../../redux/connector"; import { - Language, - LanguageEntry, - Languages as Langs, + Language, + LanguageEntry, + Languages as Langs, } from "../../../context/Locale"; import Emoji from "../../../components/common/Emoji"; @@ -15,77 +15,77 @@ import Checkbox from "../../../components/ui/Checkbox"; import Tip from "../../../components/ui/Tip"; type Props = { - locale: Language; + locale: Language; }; 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> - ); + 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, - }; + return { + locale: state.locale, + }; }); diff --git a/src/pages/settings/panes/Notifications.tsx b/src/pages/settings/panes/Notifications.tsx index af26673815dffe4a72d33a587d1d6638fcb59bfe..a7a032d98ad7fef1aa8073b077794c6280bb4c1a 100644 --- a/src/pages/settings/panes/Notifications.tsx +++ b/src/pages/settings/panes/Notifications.tsx @@ -9,9 +9,9 @@ import { urlBase64ToUint8Array } from "../../../lib/conversion"; import { dispatch } from "../../../redux"; import { connectState } from "../../../redux/connector"; import { - DEFAULT_SOUNDS, - NotificationOptions, - SoundOptions, + DEFAULT_SOUNDS, + NotificationOptions, + SoundOptions, } from "../../../redux/reducers/settings"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; @@ -22,133 +22,133 @@ 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, - }; + return { + options: state.settings.notification, + }; }); diff --git a/src/pages/settings/panes/Profile.tsx b/src/pages/settings/panes/Profile.tsx index 6e1fd4d2394017f8e287f874e4b2ef39eaf6c3d9..b9fe3a27007e141f1033428be5383406565d4f57 100644 --- a/src/pages/settings/panes/Profile.tsx +++ b/src/pages/settings/panes/Profile.tsx @@ -9,178 +9,178 @@ import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import { UserProfile } from "../../../context/intermediate/popovers/UserProfile"; import { FileUploader } from "../../../context/revoltjs/FileUploads"; import { - ClientStatus, - StatusContext, + ClientStatus, + StatusContext, } from "../../../context/revoltjs/RevoltClient"; import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks"; import AutoComplete, { - useAutoComplete, + 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 38a85576f1ba68940a1cc73f2694a634641306cc..e543da14d4713611e84da557d384a0f4a0ea2a35 100644 --- a/src/pages/settings/panes/Sessions.tsx +++ b/src/pages/settings/panes/Sessions.tsx @@ -1,14 +1,14 @@ import { HelpCircle } from "@styled-icons/boxicons-regular"; import { - Android, - Firefoxbrowser, - Googlechrome, - Ios, - Linux, - Macos, - Microsoftedge, - Safari, - Windows, + Android, + Firefoxbrowser, + Googlechrome, + Ios, + Linux, + Macos, + Microsoftedge, + Safari, + Windows, } from "@styled-icons/simple-icons"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; @@ -28,159 +28,159 @@ import Tip from "../../../components/ui/Tip"; 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 fec2548deb3d281cf11567d330bb5db18a4c3743..681ab3a1284a2fc81fa7a919346ff8dad93b6f9a 100644 --- a/src/pages/settings/panes/Sync.tsx +++ b/src/pages/settings/panes/Sync.tsx @@ -8,49 +8,49 @@ 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, - }; + return { + options: state.sync, + }; }); diff --git a/src/pages/settings/server/Bans.tsx b/src/pages/settings/server/Bans.tsx index 1c92eb81f7c352ee0bcd734341df82b169a1a996..af369d8e8f85890c3f4caaf0f8657c5db3ec8f7f 100644 --- a/src/pages/settings/server/Bans.tsx +++ b/src/pages/settings/server/Bans.tsx @@ -7,31 +7,31 @@ 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 31e9fdd4e5a502cfc817ff946d34e879eae5935f..dfef77727c5d31ab622db9121b712341df550d01 100644 --- a/src/pages/settings/server/Invites.tsx +++ b/src/pages/settings/server/Invites.tsx @@ -6,9 +6,9 @@ import { Text } from "preact-i18n"; import { useEffect, useState } from "preact/hooks"; import { - useChannels, - useForceUpdate, - useUsers, + useChannels, + useForceUpdate, + useUsers, } from "../../../context/revoltjs/hooks"; import { getChannelName } from "../../../context/revoltjs/util"; @@ -17,70 +17,70 @@ 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 faff176047316e422e39aac488bb2def5f665313..9bfeb9dad171286adc874e36bd72f43974d52918 100644 --- a/src/pages/settings/server/Members.tsx +++ b/src/pages/settings/server/Members.tsx @@ -6,39 +6,39 @@ 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 2f32ee73057a39a0ba1e66dec29f2db49c93b668..3b5ea09e58f1ca96be0c081145426ee4ec1e28b3 100644 --- a/src/pages/settings/server/Overview.tsx +++ b/src/pages/settings/server/Overview.tsx @@ -16,178 +16,178 @@ 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 78f9b8113f35a90744cf45da21fc3418e02f6e65..2a84bc5b8dad003a5a58fe3a6f7d98522cd614e7 100644 --- a/src/pages/settings/server/Roles.tsx +++ b/src/pages/settings/server/Roles.tsx @@ -2,8 +2,8 @@ import { Plus } from "@styled-icons/boxicons-regular"; import isEqual from "lodash.isequal"; import { Servers } from "revolt.js/dist/api/objects"; import { - ChannelPermission, - ServerPermission, + ChannelPermission, + ServerPermission, } from "revolt.js/dist/api/permissions"; import styles from "./Panes.module.scss"; @@ -23,157 +23,157 @@ 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); // ! 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 7bc87e12d676ddeac2844a467b0a325df626c5ed..bd89e25fb85c641af77b845f7521c558ba8b8c25 100644 --- a/src/redux/State.tsx +++ b/src/redux/State.tsx @@ -7,22 +7,22 @@ import { dispatch, State, store } from "."; import { Children } from "../types/Preact"; interface Props { - children: Children; + children: Children; } export default function StateLoader(props: Props) { - const [loaded, setLoaded] = useState(false); + 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 16eef2e7cdcfe75b47c110a63afd16c5fb837c5a..f4953f0ef4bddc278f3f946b40c28ca57568fb53 100644 --- a/src/redux/connector.tsx +++ b/src/redux/connector.tsx @@ -7,10 +7,10 @@ import { memo } from "preact/compat"; 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 b1969de87d30063fae1a490f5594ef3029d9fbaa..24db71bf22f05507080c3393168819d67735749e 100644 --- a/src/redux/index.ts +++ b/src/redux/index.ts @@ -18,67 +18,67 @@ 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 a91a88ec2e893b5249919f57f4547edc83146110..f253334668853d5d1150729969d37f966bb53d70 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 d34c29bdcfade54ae17a390a3521031c753e7d0f..4f36a84655c7f2a5526674926f75e63133fcb7cc 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 1e3b90891f3ab7794756b2b5e6c5dca605f5d317..66d095b60f5148601805bf61c90a143ceb4084da 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 83e3fb3fdc5cf8444bdb83b89ba7ef395b6294df..029d5f5143ba02b3838a6b9cc5c9a6e510186ae6 100644 --- a/src/redux/reducers/index.ts +++ b/src/redux/reducers/index.ts @@ -16,45 +16,45 @@ 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 b4c89d18837c27d8098c5df392f049f27925de88..e49c444ec8552f4d2b55c23dca7bbcc7b493b468 100644 --- a/src/redux/reducers/last_opened.ts +++ b/src/redux/reducers/last_opened.ts @@ -1,32 +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, + 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; - } + 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 fd695a1e463ea08ee32d3e495a1ac109b6a0a875..20c90ab833673d1f95edca1d9d983da90003e47b 100644 --- a/src/redux/reducers/locale.ts +++ b/src/redux/reducers/locale.ts @@ -3,50 +3,50 @@ 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 cb0f34dcaa6b45439b94f75bc53c59ce475b9c5b..ace9d56f5249b08905533ce7598a24a524af7eb7 100644 --- a/src/redux/reducers/notifications.ts +++ b/src/redux/reducers/notifications.ts @@ -5,78 +5,78 @@ import type { SyncUpdateAction } from "./sync"; 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; + [key in Channel["channel_type"]]: NotificationState; } = { - SavedMessages: "all", - DirectMessage: "all", - Group: "all", - TextChannel: "mention", - VoiceChannel: "mention", + SavedMessages: "all", + DirectMessage: "all", + Group: "all", + TextChannel: "mention", + VoiceChannel: "mention", }; export function getNotificationState( - notifications: Notifications, - channel: Channel, + notifications: Notifications, + channel: Channel, ) { - return notifications[channel._id] ?? DEFAULT_STATES[channel.channel_type]; + return notifications[channel._id] ?? DEFAULT_STATES[channel.channel_type]; } export function shouldNotify( - state: NotificationState, - message: Message, - user_id: string, + 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; - } - } + 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 20020e0ffdea55af43859f98307ac3c373220684..ce8ee124345b1d0f93d5d588fdf7f580cd1bd1cf 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[]; + 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 68657fb37be17b7099b198a1a42b3d9fcae88a8f..cff440d08b430a41880b78a9c4bc73fb986a47c1 100644 --- a/src/redux/reducers/section_toggle.ts +++ b/src/redux/reducers/section_toggle.ts @@ -1,40 +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, + 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; - } + 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 a33ef1632d250ce2300077b3fd3b8b15cd99b8e4..1b4da1df36f2b0a20c9a510ae0de2bc7075fa644 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 cf7dd124efae32d672f81f0a376cb62dc6c8ce4e..d5c898f3aceb61e9f59ec2d3e1e3a2351adbe461 100644 --- a/src/redux/reducers/settings.ts +++ b/src/redux/reducers/settings.ts @@ -7,106 +7,106 @@ 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 5e465ee48f8c32f566265475739d92a59878a390..e62c31c6c86f1763abc76e509c1a949b32dc4cab 100644 --- a/src/redux/reducers/sync.ts +++ b/src/redux/reducers/sync.ts @@ -7,88 +7,88 @@ 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 9dcbc594cbf4f2ed0c8b28434416ad7c0b3890df..41a08e2a3ee31d61c4fd86e7d149b7dec4a59c97 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 e9e1a9a7a40bbc1f9b7c73cbc38751de533d5b01..1f1b2fb15e16da8610873dfccd14a2bef30db819 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/sw.ts b/src/sw.ts index bf237fa2230384c354cc858bebb94bf7f2641f3b..b2df5a932a11528bdc21369de229dbe6db3f5b27 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -7,14 +7,14 @@ import { precacheAndRoute } from "workbox-precaching"; import type { State } from "./redux"; import { - getNotificationState, - shouldNotify, + getNotificationState, + shouldNotify, } from "./redux/reducers/notifications"; declare let self: ServiceWorkerGlobalScope; self.addEventListener("message", (event) => { - if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting(); + if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting(); }); precacheAndRoute(self.__WB_MANIFEST); @@ -26,150 +26,150 @@ const ENCODING_LEN = ENCODING.length; const TIME_LEN = 10; function decodeTime(id: string) { - 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)); - }, 0); - - return time; + 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)); + }, 0); + + return time; } self.addEventListener("push", (event) => { - async function process() { - if (event.data === null) return; - let data: Message = event.data.json(); - - let item = await localStorage.getItem("state"); - if (!item) return; - - const state: State = JSON.parse(item); - const autumn_url = state.config.features.autumn.url; - const user_id = state.auth.active!; - - let db: IDBPDatabase; - try { - // Match RevoltClient.tsx#L55 - 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.", - ); - return; - } - - 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); - - 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; - if (data.attachments) { - let attachment = data.attachments[0]; - if (attachment.metadata.type === "Image") { - image = `${autumn_url}/${attachment.tag}/${attachment._id}`; - } - } - - switch (channel?.channel_type) { - case "SavedMessages": - break; - case "DirectMessage": - title = `@${username}`; - break; - case "Group": - if (user?._id === "00000000000000000000000000") { - title = channel.name; - } else { - title = `@${user?.username} - ${channel.name}`; - } - break; - case "TextChannel": - { - 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`, - image, - 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}`, - }); - } - - event.waitUntil(process()); + async function process() { + if (event.data === null) return; + let data: Message = event.data.json(); + + let item = await localStorage.getItem("state"); + if (!item) return; + + const state: State = JSON.parse(item); + const autumn_url = state.config.features.autumn.url; + const user_id = state.auth.active!; + + let db: IDBPDatabase; + try { + // Match RevoltClient.tsx#L55 + 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.", + ); + return; + } + + 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); + + 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; + if (data.attachments) { + let attachment = data.attachments[0]; + if (attachment.metadata.type === "Image") { + image = `${autumn_url}/${attachment.tag}/${attachment._id}`; + } + } + + switch (channel?.channel_type) { + case "SavedMessages": + break; + case "DirectMessage": + title = `@${username}`; + break; + case "Group": + if (user?._id === "00000000000000000000000000") { + title = channel.name; + } else { + title = `@${user?.username} - ${channel.name}`; + } + break; + case "TextChannel": + { + 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`, + image, + 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}`, + }); + } + + event.waitUntil(process()); }); // ? Open the app on notification click. // https://stackoverflow.com/a/39457287 self.addEventListener("notificationclick", function (event) { - let url = event.notification.data; - event.notification.close(); - event.waitUntil( - self.clients - .matchAll({ includeUncontrolled: true, type: "window" }) - .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]; - // If so, just focus it. - if (client.url === url && "focus" in client) { - return client.focus(); - } - } - - // If not, then open the target URL in a new window/tab. - if (self.clients.openWindow) { - return self.clients.openWindow(url); - } - }), - ); + let url = event.notification.data; + event.notification.close(); + event.waitUntil( + self.clients + .matchAll({ includeUncontrolled: true, type: "window" }) + .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]; + // If so, just focus it. + if (client.url === url && "focus" in client) { + return client.focus(); + } + } + + // If not, then open the target URL in a new window/tab. + if (self.clients.openWindow) { + return self.clients.openWindow(url); + } + }), + ); });