summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/post.go14
-rw-r--r--api/user.go2
-rw-r--r--config/config.json2
-rw-r--r--doc/developer/tests/test-emoticons.md930
-rw-r--r--doc/install/Production-Ubuntu.md2
-rw-r--r--doc/install/Release-Numbering.md8
-rw-r--r--model/incoming_webhook.go11
-rw-r--r--model/post.go46
-rw-r--r--model/search_params.go30
-rw-r--r--model/search_params_test.go142
-rw-r--r--model/user.go7
-rw-r--r--model/utils.go24
-rw-r--r--store/sql_post_store.go19
-rw-r--r--store/sql_store.go26
-rw-r--r--store/sql_user_store.go4
-rw-r--r--web/react/components/edit_channel_purpose_modal.jsx9
-rw-r--r--web/react/components/post_attachment.jsx295
-rw-r--r--web/react/components/post_attachment_list.jsx32
-rw-r--r--web/react/components/post_body.jsx4
-rw-r--r--web/react/components/post_body_additional_content.jsx56
-rw-r--r--web/react/components/search_autocomplete.jsx157
-rw-r--r--web/react/components/tutorial/tutorial_intro_screens.jsx54
-rw-r--r--web/react/components/user_profile.jsx2
-rw-r--r--web/react/components/user_settings/user_settings_general.jsx90
-rw-r--r--web/react/stores/browser_store.jsx37
-rw-r--r--web/react/stores/user_store.jsx9
-rw-r--r--web/react/utils/client.jsx1
-rw-r--r--web/react/utils/highlight.jsx51
-rw-r--r--web/react/utils/markdown.jsx351
-rw-r--r--web/react/utils/utils.jsx14
-rw-r--r--web/sass-files/sass/partials/_post.scss78
-rw-r--r--web/sass-files/sass/partials/_tutorial.scss6
-rw-r--r--web/static/images/bing.mp3bin0 -> 5760 bytes
-rw-r--r--web/templates/head.html9
-rw-r--r--web/web.go17
35 files changed, 2333 insertions, 206 deletions
diff --git a/api/post.go b/api/post.go
index b52db8752..3892d4ee8 100644
--- a/api/post.go
+++ b/api/post.go
@@ -147,7 +147,7 @@ func CreatePost(c *Context, post *model.Post, triggerWebhooks bool) (*model.Post
return rpost, nil
}
-func CreateWebhookPost(c *Context, channelId, text, overrideUsername, overrideIconUrl string) (*model.Post, *model.AppError) {
+func CreateWebhookPost(c *Context, channelId, text, overrideUsername, overrideIconUrl string, props model.StringInterface, postType string) (*model.Post, *model.AppError) {
// parse links into Markdown format
linkWithTextRegex := regexp.MustCompile(`<([^<\|]+)\|([^>]+)>`)
text = linkWithTextRegex.ReplaceAllString(text, "[${2}](${1})")
@@ -155,7 +155,7 @@ func CreateWebhookPost(c *Context, channelId, text, overrideUsername, overrideIc
linkRegex := regexp.MustCompile(`<\s*(\S*)\s*>`)
text = linkRegex.ReplaceAllString(text, "${1}")
- post := &model.Post{UserId: c.Session.UserId, ChannelId: channelId, Message: text}
+ post := &model.Post{UserId: c.Session.UserId, ChannelId: channelId, Message: text, Type: postType}
post.AddProp("from_webhook", "true")
if utils.Cfg.ServiceSettings.EnablePostUsernameOverride {
@@ -174,6 +174,14 @@ func CreateWebhookPost(c *Context, channelId, text, overrideUsername, overrideIc
}
}
+ if len(props) > 0 {
+ for key, val := range props {
+ if key != "override_icon_url" && key != "override_username" && key != "from_webhook" {
+ post.AddProp(key, val)
+ }
+ }
+ }
+
if _, err := CreatePost(c, post, false); err != nil {
return nil, model.NewAppError("CreateWebhookPost", "Error creating post", "err="+err.Message)
}
@@ -286,7 +294,7 @@ func handleWebhookEventsAndForget(c *Context, post *model.Post, team *model.Team
newContext := &Context{mockSession, model.NewId(), "", c.Path, nil, c.teamURLValid, c.teamURL, c.siteURL, 0}
if text, ok := respProps["text"]; ok {
- if _, err := CreateWebhookPost(newContext, post.ChannelId, text, respProps["username"], respProps["icon_url"]); err != nil {
+ if _, err := CreateWebhookPost(newContext, post.ChannelId, text, respProps["username"], respProps["icon_url"], post.Props, post.Type); err != nil {
l4g.Error("Failed to create response post, err=%v", err)
}
}
diff --git a/api/user.go b/api/user.go
index c871d7c79..774ceddbf 100644
--- a/api/user.go
+++ b/api/user.go
@@ -114,7 +114,7 @@ func createUser(c *Context, w http.ResponseWriter, r *http.Request) {
sendWelcomeEmail = false
}
- if len(user.AuthData) > 0 && len(user.AuthService) > 0 {
+ if user.IsSSOUser() {
user.EmailVerified = true
}
diff --git a/config/config.json b/config/config.json
index 2738546c0..a927620b5 100644
--- a/config/config.json
+++ b/config/config.json
@@ -92,4 +92,4 @@
"TokenEndpoint": "",
"UserApiEndpoint": ""
}
-}
+} \ No newline at end of file
diff --git a/doc/developer/tests/test-emoticons.md b/doc/developer/tests/test-emoticons.md
new file mode 100644
index 000000000..9ea1afec0
--- /dev/null
+++ b/doc/developer/tests/test-emoticons.md
@@ -0,0 +1,930 @@
+# Emoticon Testing
+
+The below text can be used to check for regressions in changes to text processing. Paste the below text into messages and check that emoticons continue to render after text processing changes.
+
+```
+# Emoticon - Punctuation
+
+:) :-)
+;) ;-)
+:o :O
+:-o :-O
+:] :-]
+:d :-D
+x-d x-D
+:p :-P
+:@
+:( :-(
+:'(
+:* :-*
+:/ :-/
+:s :-s
+:| :-|
+:$ :-$
+:-x
+<3
+:+1:
+:-1:
+```
+
+```
+# Emoticons - People
+
+:bowtie:
+:smile:
+:laughing:
+:blush:
+:smiley:
+:relaxed:
+:smirk:
+:heart_eyes:
+:kissing_heart:
+:kissing_closed_eyes:
+:flushed:
+:relieved:
+:satisfied:
+:grin:
+:wink:
+:stuck_out_tongue_winking_eye:
+:stuck_out_tongue_closed_eyes:
+:grinning:
+:kissing:
+:kissing_smiling_eyes:
+:stuck_out_tongue:
+:sleeping:
+:worried:
+:frowning:
+:anguished:
+:open_mouth:
+:grimacing:
+:confused:
+:hushed:
+:expressionless:
+:unamused:
+:sweat_smile:
+:sweat:
+:disappointed_relieved:
+:weary:
+:pensive:
+:disappointed:
+:confounded:
+:fearful:
+:cold_sweat:
+:persevere:
+:cry:
+:sob:
+:joy:
+:astonished:
+:scream:
+:neckbeard:
+:tired_face:
+:angry:
+:rage:
+:triumph:
+:sleepy:
+:yum:
+:mask:
+:sunglasses:
+:dizzy_face:
+:imp:
+:smiling_imp:
+:neutral_face:
+:no_mouth:
+:innocent:
+:alien:
+:yellow_heart:
+:blue_heart:
+:purple_heart:
+:heart:
+:green_heart:
+:broken_heart:
+:heartbeat:
+:heartpulse:
+:two_hearts:
+:revolving_hearts:
+:cupid:
+:sparkling_heart:
+:sparkles:
+:star:
+:star2:
+:dizzy:
+:boom:
+:collision:
+:anger:
+:exclamation:
+:question:
+:grey_exclamation:
+:grey_question:
+:zzz:
+:dash:
+:sweat_drops:
+:notes:
+:musical_note:
+:fire:
+:hankey:
+:poop:
+:shit:
+:+1:
+:thumbsup:
+:-1:
+:thumbsdown:
+:ok_hand:
+:punch:
+:facepunch:
+:fist:
+:v:
+:wave:
+:hand:
+:raised_hand:
+:open_hands:
+:point_up:
+:point_down:
+:point_left:
+:point_right:
+:raised_hands:
+:pray:
+:point_up_2:
+:clap:
+:muscle:
+:metal:
+:fu:
+:runner:
+:running:
+:couple:
+:family:
+:two_men_holding_hands:
+:two_women_holding_hands:
+:dancer:
+:dancers:
+:ok_woman:
+:no_good:
+:information_desk_person:
+:raising_hand:
+:bride_with_veil:
+:person_with_pouting_face:
+:person_frowning:
+:bow:
+:couplekiss:
+:couple_with_heart:
+:massage:
+:haircut:
+:nail_care:
+:boy:
+:girl:
+:woman:
+:man:
+:baby:
+:older_woman:
+:older_man:
+:person_with_blond_hair:
+:man_with_gua_pi_mao:
+:man_with_turban:
+:construction_worker:
+:cop:
+:angel:
+:princess:
+:smiley_cat:
+:smile_cat:
+:heart_eyes_cat:
+:kissing_cat:
+:smirk_cat:
+:scream_cat:
+:crying_cat_face:
+:joy_cat:
+:pouting_cat:
+:japanese_ogre:
+:japanese_goblin:
+:see_no_evil:
+:hear_no_evil:
+:speak_no_evil:
+:guardsman:
+:skull:
+:feet:
+:lips:
+:kiss:
+:droplet:
+:ear:
+:eyes:
+:nose:
+:tongue:
+:love_letter:
+:bust_in_silhouette:
+:busts_in_silhouette:
+:speech_balloon:
+:thought_balloon:
+:feelsgood:
+:finnadie:
+:goberserk:
+:godmode:
+:hurtrealbad:
+:rage1:
+:rage2:
+:rage3:
+:rage4:
+:suspect:
+:trollface:
+```
+
+```
+# Emoticons - Nature
+
+:sunny:
+:umbrella:
+:cloud:
+:snowflake:
+:snowman:
+:zap:
+:cyclone:
+:foggy:
+:ocean:
+:cat:
+:dog:
+:mouse:
+:hamster:
+:rabbit:
+:wolf:
+:frog:
+:tiger:
+:koala:
+:bear:
+:pig:
+:pig_nose:
+:cow:
+:boar:
+:monkey_face:
+:monkey:
+:horse:
+:racehorse:
+:camel:
+:sheep:
+:elephant:
+:panda_face:
+:snake:
+:bird:
+:baby_chick:
+:hatched_chick:
+:hatching_chick:
+:chicken:
+:penguin:
+:turtle:
+:bug:
+:honeybee:
+:ant:
+:beetle:
+:snail:
+:octopus:
+:tropical_fish:
+:fish:
+:whale:
+:whale2:
+:dolphin:
+:cow2:
+:ram:
+:rat:
+:water_buffalo:
+:tiger2:
+:rabbit2:
+:dragon:
+:goat:
+:rooster:
+:dog2:
+:pig2:
+:mouse2:
+:ox:
+:dragon_face:
+:blowfish:
+:crocodile:
+:dromedary_camel:
+:leopard:
+:cat2:
+:poodle:
+:paw_prints:
+:bouquet:
+:cherry_blossom:
+:tulip:
+:four_leaf_clover:
+:rose:
+:sunflower:
+:hibiscus:
+:maple_leaf:
+:leaves:
+:fallen_leaf:
+:herb:
+:mushroom:
+:cactus:
+:palm_tree:
+:evergreen_tree:
+:deciduous_tree:
+:chestnut:
+:seedling:
+:blossom:
+:ear_of_rice:
+:shell:
+:globe_with_meridians:
+:sun_with_face:
+:full_moon_with_face:
+:new_moon_with_face:
+:new_moon:
+:waxing_crescent_moon:
+:first_quarter_moon:
+:waxing_gibbous_moon:
+:full_moon:
+:waning_gibbous_moon:
+:last_quarter_moon:
+:waning_crescent_moon:
+:last_quarter_moon_with_face:
+:first_quarter_moon_with_face:
+:crescent_moon:
+:earth_africa:
+:earth_americas:
+:earth_asia:
+:volcano:
+:milky_way:
+:partly_sunny:
+:octocat:
+:squirrel:
+```
+
+```
+# Emoticons - Objects
+
+:bamboo:
+:gift_heart:
+:dolls:
+:school_satchel:
+:mortar_board:
+:flags:
+:fireworks:
+:sparkler:
+:wind_chime:
+:rice_scene:
+:jack_o_lantern:
+:ghost:
+:santa:
+:christmas_tree:
+:gift:
+:bell:
+:no_bell:
+:tanabata_tree:
+:tada:
+:confetti_ball:
+:balloon:
+:crystal_ball:
+:cd:
+:dvd:
+:floppy_disk:
+:camera:
+:video_camera:
+:movie_camera:
+:computer:
+:tv:
+:iphone:
+:phone:
+:telephone:
+:telephone_receiver:
+:pager:
+:fax:
+:minidisc:
+:vhs:
+:sound:
+:speaker:
+:mute:
+:loudspeaker:
+:mega:
+:hourglass:
+:hourglass_flowing_sand:
+:alarm_clock:
+:watch:
+:radio:
+:satellite:
+:loop:
+:mag:
+:mag_right:
+:unlock:
+:lock:
+:lock_with_ink_pen:
+:closed_lock_with_key:
+:key:
+:bulb:
+:flashlight:
+:high_brightness:
+:low_brightness:
+:electric_plug:
+:battery:
+:calling:
+:email:
+:mailbox:
+:postbox:
+:bath:
+:bathtub:
+:shower:
+:toilet:
+:wrench:
+:nut_and_bolt:
+:hammer:
+:seat:
+:moneybag:
+:yen:
+:dollar:
+:pound:
+:euro:
+:credit_card:
+:money_with_wings:
+:e-mail:
+:inbox_tray:
+:outbox_tray:
+:envelope:
+:incoming_envelope:
+:postal_horn:
+:mailbox_closed:
+:mailbox_with_mail:
+:mailbox_with_no_mail:
+:package:
+:door:
+:smoking:
+:bomb:
+:gun:
+:hocho:
+:pill:
+:syringe:
+:page_facing_up:
+:page_with_curl:
+:bookmark_tabs:
+:bar_chart:
+:chart_with_upwards_trend:
+:chart_with_downwards_trend:
+:scroll:
+:clipboard:
+:calendar:
+:date:
+:card_index:
+:file_folder:
+:open_file_folder:
+:scissors:
+:pushpin:
+:paperclip:
+:black_nib:
+:pencil2:
+:straight_ruler:
+:triangular_ruler:
+:closed_book:
+:green_book:
+:blue_book:
+:orange_book:
+:notebook:
+:notebook_with_decorative_cover:
+:ledger:
+:books:
+:bookmark:
+:name_badge:
+:microscope:
+:telescope:
+:newspaper:
+:football:
+:basketball:
+:soccer:
+:baseball:
+:tennis:
+:8ball:
+:8ball:
+:rugby_football:
+:bowling:
+:golf:
+:mountain_bicyclist:
+:bicyclist:
+:horse_racing:
+:snowboarder:
+:swimmer:
+:surfer:
+:ski:
+:spades:
+:hearts:
+:clubs:
+:diamonds:
+:gem:
+:ring:
+:trophy:
+:musical_score:
+:musical_keyboard:
+:violin:
+:space_invader:
+:video_game:
+:black_joker:
+:flower_playing_cards:
+:game_die:
+:dart:
+:mahjong:
+:clapper:
+:memo:
+:pencil:
+:book:
+:art:
+:microphone:
+:headphones:
+:trumpet:
+:saxophone:
+:guitar:
+:shoe:
+:sandal:
+:high_heel:
+:lipstick:
+:boot:
+:shirt:
+:tshirt:
+:necktie:
+:womans_clothes:
+:dress:
+:running_shirt_with_sash:
+:jeans:
+:kimono:
+:bikini:
+:ribbon:
+:tophat:
+:crown:
+:womans_hat:
+:mans_shoe:
+:closed_umbrella:
+:briefcase:
+:handbag:
+:pouch:
+:purse:
+:eyeglasses:
+:fishing_pole_and_fish:
+:coffee:
+:tea:
+:sake:
+:baby_bottle:
+:beer:
+:beers:
+:cocktail:
+:tropical_drink:
+:wine_glass:
+:fork_and_knife:
+:pizza:
+:hamburger:
+:fries:
+:poultry_leg:
+:meat_on_bone:
+:spaghetti:
+:curry:
+:fried_shrimp:
+:bento:
+:sushi:
+:fish_cake:
+:rice_ball:
+:rice_cracker:
+:rice:
+:ramen:
+:stew:
+:oden:
+:dango:
+:egg:
+:bread:
+:doughnut:
+:custard:
+:icecream:
+:ice_cream:
+:shaved_ice:
+:birthday:
+:cake:
+:cookie:
+:chocolate_bar:
+:candy:
+:lollipop:
+:honey_pot:
+:apple:
+:green_apple:
+:tangerine:
+:lemon:
+:cherries:
+:grapes:
+:watermelon:
+:strawberry:
+:peach:
+:melon:
+:banana:
+:pear:
+:pineapple:
+:sweet_potato:
+:eggplant:
+:tomato:
+:corn:
+```
+
+```
+# Emoticons - Places
+
+:house:
+:house_with_garden:
+:school:
+:office:
+:post_office:
+:hospital:
+:bank:
+:convenience_store:
+:love_hotel:
+:hotel:
+:wedding:
+:church:
+:department_store:
+:european_post_office:
+:city_sunrise:
+:city_sunset:
+:japanese_castle:
+:european_castle:
+:tent:
+:factory:
+:tokyo_tower:
+:japan:
+:mount_fuji:
+:sunrise_over_mountains:
+:sunrise:
+:stars:
+:statue_of_liberty:
+:bridge_at_night:
+:carousel_horse:
+:rainbow:
+:ferris_wheel:
+:fountain:
+:roller_coaster:
+:ship:
+:speedboat:
+:boat:
+:sailboat:
+:rowboat:
+:anchor:
+:rocket:
+:airplane:
+:helicopter:
+:steam_locomotive:
+:tram:
+:mountain_railway:
+:bike:
+:aerial_tramway:
+:suspension_railway:
+:mountain_cableway:
+:tractor:
+:blue_car:
+:oncoming_automobile:
+:car:
+:red_car:
+:taxi:
+:oncoming_taxi:
+:articulated_lorry:
+:bus:
+:oncoming_bus:
+:rotating_light:
+:police_car:
+:oncoming_police_car:
+:fire_engine:
+:ambulance:
+:minibus:
+:truck:
+:train:
+:station:
+:train2:
+:bullettrain_front:
+:bullettrain_side:
+:light_rail:
+:monorail:
+:railway_car:
+:trolleybus:
+:ticket:
+:fuelpump:
+:vertical_traffic_light:
+:traffic_light:
+:warning:
+:construction:
+:beginner:
+:atm:
+:slot_machine:
+:busstop:
+:barber:
+:hotsprings:
+:checkered_flag:
+:crossed_flags:
+:izakaya_lantern:
+:moyai:
+:circus_tent:
+:performing_arts:
+:round_pushpin:
+:triangular_flag_on_post:
+:jp:
+:kr:
+:cn:
+:us:
+:fr:
+:es:
+:it:
+:ru:
+:gb:
+:uk:
+:de:
+```
+
+```
+# Emoticons - Symbols
+
+:one:
+:two:
+:three:
+:four:
+:five:
+:six:
+:seven:
+:eight:
+:nine:
+:keycap_ten:
+:1234:
+:zero:
+:hash:
+:symbols:
+:arrow_backward:
+:arrow_down:
+:arrow_forward:
+:arrow_left:
+:capital_abcd:
+:abcd:
+:abc:
+:arrow_lower_left:
+:arrow_lower_right:
+:arrow_right:
+:arrow_up:
+:arrow_upper_left:
+:arrow_upper_right:
+:arrow_double_down:
+:arrow_double_up:
+:arrow_down_small:
+:arrow_heading_down:
+:arrow_heading_up:
+:leftwards_arrow_with_hook:
+:arrow_right_hook:
+:left_right_arrow:
+:arrow_up_down:
+:arrow_up_small:
+:arrows_clockwise:
+:arrows_counterclockwise:
+:rewind:
+:fast_forward:
+:information_source:
+:ok:
+:twisted_rightwards_arrows:
+:repeat:
+:repeat_one:
+:new:
+:top:
+:up:
+:cool:
+:free:
+:ng:
+:cinema:
+:koko:
+:signal_strength:
+:u5272:
+:u5408:
+:u55b6:
+:u6307:
+:u6708:
+:u6709:
+:u6e80:
+:u7121:
+:u7533:
+:u7a7a:
+:u7981:
+:sa:
+:restroom:
+:mens:
+:womens:
+:baby_symbol:
+:no_smoking:
+:parking:
+:wheelchair:
+:metro:
+:baggage_claim:
+:accept:
+:wc:
+:potable_water:
+:put_litter_in_its_place:
+:secret:
+:congratulations:
+:m:
+:passport_control:
+:left_luggage:
+:customs:
+:ideograph_advantage:
+:cl:
+:sos:
+:id:
+:no_entry_sign:
+:underage:
+:no_mobile_phones:
+:do_not_litter:
+:non-potable_water:
+:no_bicycles:
+:no_pedestrians:
+:children_crossing:
+:no_entry:
+:eight_spoked_asterisk:
+:sparkle:
+:eight_pointed_black_star:
+:heart_decoration:
+:vs:
+:vibration_mode:
+:mobile_phone_off:
+:chart:
+:currency_exchange:
+:aries:
+:taurus:
+:gemini:
+:cancer:
+:leo:
+:virgo:
+:libra:
+:scorpius:
+:sagittarius:
+:capricorn:
+:aquarius:
+:pisces:
+:ophiuchus:
+:six_pointed_star:
+:negative_squared_cross_mark:
+:a:
+:b:
+:ab:
+:o2:
+:diamond_shape_with_a_dot_inside:
+:recycle:
+:end:
+:back:
+:on:
+:soon:
+:clock1:
+:clock130:
+:clock10:
+:clock1030:
+:clock11:
+:clock1130:
+:clock12:
+:clock1230:
+:clock2:
+:clock230:
+:clock3:
+:clock330:
+:clock4:
+:clock430:
+:clock5:
+:clock530:
+:clock6:
+:clock630:
+:clock7:
+:clock730:
+:clock8:
+:clock830:
+:clock9:
+:clock930:
+:heavy_dollar_sign:
+:copyright:
+:registered:
+:tm:
+:x:
+:heavy_exclamation_mark:
+:bangbang:
+:interrobang:
+:o:
+:heavy_multiplication_x:
+:heavy_plus_sign:
+:heavy_minus_sign:
+:heavy_division_sign:
+:white_flower:
+:100:
+:heavy_check_mark:
+:ballot_box_with_check:
+:radio_button:
+:link:
+:curly_loop:
+:wavy_dash:
+:part_alternation_mark:
+:trident:
+:black_small_square:
+:white_small_square:
+:black_medium_small_square:
+:white_medium_small_square:
+:black_medium_square:
+:white_medium_square:
+:black_large_square:
+:white_large_square:
+:white_check_mark:
+:black_square_button:
+:white_square_button:
+:black_circle:
+:white_circle:
+:red_circle:
+:large_blue_circle:
+:large_blue_diamond:
+:large_orange_diamond:
+:small_blue_diamond:
+:small_orange_diamond:
+:small_red_triangle:
+:small_red_triangle_down:
+:shipit:
+```
diff --git a/doc/install/Production-Ubuntu.md b/doc/install/Production-Ubuntu.md
index 2e02cca38..ec0c1c96b 100644
--- a/doc/install/Production-Ubuntu.md
+++ b/doc/install/Production-Ubuntu.md
@@ -103,7 +103,7 @@ exec bin/platform
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
- proxy_pass http://localhost:8065;
+ proxy_pass http://10.10.10.2:8065;
}
}
```
diff --git a/doc/install/Release-Numbering.md b/doc/install/Release-Numbering.md
index 3b0391cff..41aba109f 100644
--- a/doc/install/Release-Numbering.md
+++ b/doc/install/Release-Numbering.md
@@ -1,4 +1,10 @@
-### Mattermost Release Numbering
+# Mattermost Release Schedule and Numbering
+
+## Release Schedule
+
+Mattermost releases stable builds monthly on the 16th in [binary form](https://github.com/mattermost/platform/releases)
+
+## Release Numbering
Mattermost numbers its stable releases based on the following format:
diff --git a/model/incoming_webhook.go b/model/incoming_webhook.go
index be1984244..8ead0da9f 100644
--- a/model/incoming_webhook.go
+++ b/model/incoming_webhook.go
@@ -24,10 +24,13 @@ type IncomingWebhook struct {
}
type IncomingWebhookRequest struct {
- Text string `json:"text"`
- Username string `json:"username"`
- IconURL string `json:"icon_url"`
- ChannelName string `json:"channel"`
+ Text string `json:"text"`
+ Username string `json:"username"`
+ IconURL string `json:"icon_url"`
+ ChannelName string `json:"channel"`
+ Props StringInterface `json:"props"`
+ Attachments interface{} `json:"attachments"`
+ Type string `json:"type"`
}
func (o *IncomingWebhook) ToJson() string {
diff --git a/model/post.go b/model/post.go
index e0074b348..248d40321 100644
--- a/model/post.go
+++ b/model/post.go
@@ -10,27 +10,28 @@ import (
)
const (
- POST_DEFAULT = ""
- POST_JOIN_LEAVE = "join_leave"
+ POST_DEFAULT = ""
+ POST_SLACK_ATTACHMENT = "slack_attachment"
+ POST_JOIN_LEAVE = "join_leave"
)
type Post struct {
- Id string `json:"id"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- DeleteAt int64 `json:"delete_at"`
- UserId string `json:"user_id"`
- ChannelId string `json:"channel_id"`
- RootId string `json:"root_id"`
- ParentId string `json:"parent_id"`
- OriginalId string `json:"original_id"`
- Message string `json:"message"`
- ImgCount int64 `json:"img_count"`
- Type string `json:"type"`
- Props StringMap `json:"props"`
- Hashtags string `json:"hashtags"`
- Filenames StringArray `json:"filenames"`
- PendingPostId string `json:"pending_post_id" db:"-"`
+ Id string `json:"id"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ DeleteAt int64 `json:"delete_at"`
+ UserId string `json:"user_id"`
+ ChannelId string `json:"channel_id"`
+ RootId string `json:"root_id"`
+ ParentId string `json:"parent_id"`
+ OriginalId string `json:"original_id"`
+ Message string `json:"message"`
+ ImgCount int64 `json:"img_count"`
+ Type string `json:"type"`
+ Props StringInterface `json:"props"`
+ Hashtags string `json:"hashtags"`
+ Filenames StringArray `json:"filenames"`
+ PendingPostId string `json:"pending_post_id" db:"-"`
}
func (o *Post) ToJson() string {
@@ -103,7 +104,8 @@ func (o *Post) IsValid() *AppError {
return NewAppError("Post.IsValid", "Invalid hashtags", "id="+o.Id)
}
- if !(o.Type == POST_DEFAULT || o.Type == POST_JOIN_LEAVE) {
+ // should be removed once more message types are supported
+ if !(o.Type == POST_DEFAULT || o.Type == POST_JOIN_LEAVE || o.Type == POST_SLACK_ATTACHMENT) {
return NewAppError("Post.IsValid", "Invalid type", "id="+o.Type)
}
@@ -128,7 +130,7 @@ func (o *Post) PreSave() {
o.UpdateAt = o.CreateAt
if o.Props == nil {
- o.Props = make(map[string]string)
+ o.Props = make(map[string]interface{})
}
if o.Filenames == nil {
@@ -138,14 +140,14 @@ func (o *Post) PreSave() {
func (o *Post) MakeNonNil() {
if o.Props == nil {
- o.Props = make(map[string]string)
+ o.Props = make(map[string]interface{})
}
if o.Filenames == nil {
o.Filenames = []string{}
}
}
-func (o *Post) AddProp(key string, value string) {
+func (o *Post) AddProp(key string, value interface{}) {
o.MakeNonNil()
diff --git a/model/search_params.go b/model/search_params.go
index 144e8e461..17a64d980 100644
--- a/model/search_params.go
+++ b/model/search_params.go
@@ -16,7 +16,7 @@ type SearchParams struct {
var searchFlags = [...]string{"from", "channel", "in"}
-func splitWords(text string) []string {
+func splitWordsNoQuotes(text string) []string {
words := []string{}
for _, word := range strings.Fields(text) {
@@ -31,6 +31,32 @@ func splitWords(text string) []string {
return words
}
+func splitWords(text string) []string {
+ words := []string{}
+
+ foundQuote := false
+ location := 0
+ for i, char := range text {
+ if char == '"' {
+ if foundQuote {
+ // Grab the quoted section
+ word := text[location : i+1]
+ words = append(words, word)
+ foundQuote = false
+ location = i + 1
+ } else {
+ words = append(words, splitWordsNoQuotes(text[location:i])...)
+ foundQuote = true
+ location = i
+ }
+ }
+ }
+
+ words = append(words, splitWordsNoQuotes(text[location:])...)
+
+ return words
+}
+
func parseSearchFlags(input []string) ([]string, [][2]string) {
words := []string{}
flags := [][2]string{}
@@ -127,7 +153,7 @@ func ParseSearchParams(text string) []*SearchParams {
}
// special case for when no terms are specified but we still have a filter
- if len(plainTerms) == 0 && len(hashtagTerms) == 0 {
+ if len(plainTerms) == 0 && len(hashtagTerms) == 0 && (len(inChannels) != 0 || len(fromUsers) != 0) {
paramsList = append(paramsList, &SearchParams{
Terms: "",
IsHashtag: true,
diff --git a/model/search_params_test.go b/model/search_params_test.go
index e03e82c5a..af4cbe595 100644
--- a/model/search_params_test.go
+++ b/model/search_params_test.go
@@ -7,11 +7,73 @@ import (
"testing"
)
+func TestSplitWords(t *testing.T) {
+ if words := splitWords(""); len(words) != 0 {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords(" "); len(words) != 0 {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords("word"); len(words) != 1 || words[0] != "word" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords("wo\"rd"); len(words) != 2 || words[0] != "wo" || words[1] != "\"rd" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords("wo\"rd\""); len(words) != 2 || words[0] != "wo" || words[1] != "\"rd\"" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords("word1 word2 word3"); len(words) != 3 || words[0] != "word1" || words[1] != "word2" || words[2] != "word3" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords("word1 \"word2 word3"); len(words) != 3 || words[0] != "word1" || words[1] != "\"word2" || words[2] != "word3" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords("\"word1 word2 word3"); len(words) != 3 || words[0] != "\"word1" || words[1] != "word2" || words[2] != "word3" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords("word1 word2 word3\""); len(words) != 4 || words[0] != "word1" || words[1] != "word2" || words[2] != "word3" || words[3] != "\"" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords("word1 #word2 ##word3"); len(words) != 3 || words[0] != "word1" || words[1] != "#word2" || words[2] != "##word3" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords(" word1 word2 word3 "); len(words) != 3 || words[0] != "word1" || words[1] != "word2" || words[2] != "word3" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords("\"quoted\""); len(words) != 1 || words[0] != "\"quoted\"" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords("\"quoted multiple words\""); len(words) != 1 || words[0] != "\"quoted multiple words\"" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords("some stuff \"quoted multiple words\" more stuff"); len(words) != 5 || words[0] != "some" || words[1] != "stuff" || words[2] != "\"quoted multiple words\"" || words[3] != "more" || words[4] != "stuff" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+
+ if words := splitWords("some \"stuff\" \"quoted multiple words\" #some \"more stuff\""); len(words) != 5 || words[0] != "some" || words[1] != "\"stuff\"" || words[2] != "\"quoted multiple words\"" || words[3] != "#some" || words[4] != "\"more stuff\"" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ }
+}
+
func TestParseSearchFlags(t *testing.T) {
if words, flags := parseSearchFlags(splitWords("")); len(words) != 0 {
- t.Fatal("got words from empty input")
+ t.Fatalf("got words from empty input")
} else if len(flags) != 0 {
- t.Fatal("got flags from empty input")
+ t.Fatalf("got flags from empty input")
}
if words, flags := parseSearchFlags(splitWords("word")); len(words) != 1 || words[0] != "word" {
@@ -32,6 +94,12 @@ func TestParseSearchFlags(t *testing.T) {
t.Fatalf("got incorrect flags %v", flags)
}
+ if words, flags := parseSearchFlags(splitWords("#apple #banana from:chan")); len(words) != 2 || words[0] != "#apple" || words[1] != "#banana" {
+ t.Fatalf("got incorrect words %v", words)
+ } else if len(flags) != 1 || flags[0][0] != "from" || flags[0][1] != "chan" {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+
if words, flags := parseSearchFlags(splitWords("apple banana from: chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" {
t.Fatalf("got incorrect words %v", words)
} else if len(flags) != 1 || flags[0][0] != "from" || flags[0][1] != "chan" {
@@ -74,4 +142,74 @@ func TestParseSearchFlags(t *testing.T) {
flags[2][0] != "from" || flags[2][1] != "third" || flags[3][0] != "from" || flags[3][1] != "fourth" {
t.Fatalf("got incorrect flags %v", flags)
}
+
+ if words, flags := parseSearchFlags(splitWords("\"quoted\"")); len(words) != 1 || words[0] != "\"quoted\"" {
+ t.Fatalf("got incorrect words %v", words)
+ } else if len(flags) != 0 {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+
+ if words, flags := parseSearchFlags(splitWords("\"quoted multiple words\"")); len(words) != 1 || words[0] != "\"quoted multiple words\"" {
+ t.Fatalf("got incorrect words %v", words)
+ } else if len(flags) != 0 {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+
+ if words, flags := parseSearchFlags(splitWords("some \"stuff\" \"quoted multiple words\" some \"more stuff\"")); len(words) != 5 || words[0] != "some" || words[1] != "\"stuff\"" || words[2] != "\"quoted multiple words\"" || words[3] != "some" || words[4] != "\"more stuff\"" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ } else if len(flags) != 0 {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+
+ if words, flags := parseSearchFlags(splitWords("some in:here \"stuff\" \"quoted multiple words\" from:someone \"more stuff\"")); len(words) != 4 || words[0] != "some" || words[1] != "\"stuff\"" || words[2] != "\"quoted multiple words\"" || words[3] != "\"more stuff\"" {
+ t.Fatalf("Incorrect output splitWords: %v", words)
+ } else if len(flags) != 2 || flags[0][0] != "in" || flags[0][1] != "here" || flags[1][0] != "from" || flags[1][1] != "someone" {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+}
+
+func TestParseSearchParams(t *testing.T) {
+ if sp := ParseSearchParams(""); len(sp) != 0 {
+ t.Fatalf("Incorrect output from parse search params: %v", sp)
+ }
+
+ if sp := ParseSearchParams(" "); len(sp) != 0 {
+ t.Fatalf("Incorrect output from parse search params: %v", sp)
+ }
+
+ if sp := ParseSearchParams("words words"); len(sp) != 1 || sp[0].Terms != "words words" || sp[0].IsHashtag != false || len(sp[0].InChannels) != 0 || len(sp[0].FromUsers) != 0 {
+ t.Fatalf("Incorrect output from parse search params: %v", sp)
+ }
+
+ if sp := ParseSearchParams("\"my stuff\""); len(sp) != 1 || sp[0].Terms != "\"my stuff\"" || sp[0].IsHashtag != false || len(sp[0].InChannels) != 0 || len(sp[0].FromUsers) != 0 {
+ t.Fatalf("Incorrect output from parse search params: %v", sp)
+ }
+
+ if sp := ParseSearchParams("#words #words"); len(sp) != 1 || sp[0].Terms != "#words #words" || sp[0].IsHashtag != true || len(sp[0].InChannels) != 0 || len(sp[0].FromUsers) != 0 {
+ t.Fatalf("Incorrect output from parse search params: %v", sp)
+ }
+
+ if sp := ParseSearchParams("#words words"); len(sp) != 2 || sp[1].Terms != "#words" || sp[1].IsHashtag != true || len(sp[1].InChannels) != 0 || len(sp[1].FromUsers) != 0 || sp[0].Terms != "words" || sp[0].IsHashtag != false || len(sp[0].InChannels) != 0 {
+ t.Fatalf("Incorrect output from parse search params: %v", sp)
+ }
+
+ if sp := ParseSearchParams("in:channel"); len(sp) != 1 || sp[0].Terms != "" || len(sp[0].InChannels) != 1 || sp[0].InChannels[0] != "channel" || len(sp[0].FromUsers) != 0 {
+ t.Fatalf("Incorrect output from parse search params: %v", sp)
+ }
+
+ if sp := ParseSearchParams("testing in:channel"); len(sp) != 1 || sp[0].Terms != "testing" || len(sp[0].InChannels) != 1 || sp[0].InChannels[0] != "channel" || len(sp[0].FromUsers) != 0 {
+ t.Fatalf("Incorrect output from parse search params: %v", sp)
+ }
+
+ if sp := ParseSearchParams("in:channel testing"); len(sp) != 1 || sp[0].Terms != "testing" || len(sp[0].InChannels) != 1 || sp[0].InChannels[0] != "channel" || len(sp[0].FromUsers) != 0 {
+ t.Fatalf("Incorrect output from parse search params: %v", sp)
+ }
+
+ if sp := ParseSearchParams("in:channel in:otherchannel"); len(sp) != 1 || sp[0].Terms != "" || len(sp[0].InChannels) != 2 || sp[0].InChannels[0] != "channel" || sp[0].InChannels[1] != "otherchannel" || len(sp[0].FromUsers) != 0 {
+ t.Fatalf("Incorrect output from parse search params: %v", sp)
+ }
+
+ if sp := ParseSearchParams("testing in:channel from:someone"); len(sp) != 1 || sp[0].Terms != "testing" || len(sp[0].InChannels) != 1 || sp[0].InChannels[0] != "channel" || len(sp[0].FromUsers) != 1 || sp[0].FromUsers[0] != "someone" {
+ t.Fatalf("Incorrect output from parse search params: %v", sp[0])
+ }
}
diff --git a/model/user.go b/model/user.go
index 871d1bf2d..4365f47d2 100644
--- a/model/user.go
+++ b/model/user.go
@@ -326,6 +326,13 @@ func IsInRole(userRoles string, inRole string) bool {
return false
}
+func (u *User) IsSSOUser() bool {
+ if len(u.AuthData) != 0 && len(u.AuthService) != 0 {
+ return true
+ }
+ return false
+}
+
func (u *User) PreExport() {
u.Password = ""
u.AuthData = ""
diff --git a/model/utils.go b/model/utils.go
index 681ade870..6d6eb452d 100644
--- a/model/utils.go
+++ b/model/utils.go
@@ -17,6 +17,7 @@ import (
"time"
)
+type StringInterface map[string]interface{}
type StringMap map[string]string
type StringArray []string
type EncryptStringMap map[string]string
@@ -125,6 +126,25 @@ func ArrayFromJson(data io.Reader) []string {
}
}
+func StringInterfaceToJson(objmap map[string]interface{}) string {
+ if b, err := json.Marshal(objmap); err != nil {
+ return ""
+ } else {
+ return string(b)
+ }
+}
+
+func StringInterfaceFromJson(data io.Reader) map[string]interface{} {
+ decoder := json.NewDecoder(data)
+
+ var objmap map[string]interface{}
+ if err := decoder.Decode(&objmap); err != nil {
+ return make(map[string]interface{})
+ } else {
+ return objmap
+ }
+}
+
func IsLower(s string) bool {
if strings.ToLower(s) == s {
return true
@@ -242,8 +262,8 @@ func Etag(parts ...interface{}) string {
}
var validHashtag = regexp.MustCompile(`^(#[A-Za-z]+[A-Za-z0-9_\-]*[A-Za-z0-9])$`)
-var puncStart = regexp.MustCompile(`^[.,()&$!\[\]{}"':;\\]+`)
-var puncEnd = regexp.MustCompile(`[.,()&$#!\[\]{}"';\\]+$`)
+var puncStart = regexp.MustCompile(`^[.,()&$!\[\]{}':;\\]+`)
+var puncEnd = regexp.MustCompile(`[.,()&$#!\[\]{}';\\]+$`)
func ParseHashtags(text string) (string, string) {
words := strings.Fields(text)
diff --git a/store/sql_post_store.go b/store/sql_post_store.go
index fdae20f60..3460fca92 100644
--- a/store/sql_post_store.go
+++ b/store/sql_post_store.go
@@ -9,8 +9,10 @@ import (
"strconv"
"strings"
+ l4g "code.google.com/p/log4go"
"github.com/mattermost/platform/model"
"github.com/mattermost/platform/utils"
+ "time"
)
type SqlPostStore struct {
@@ -30,7 +32,7 @@ func NewSqlPostStore(sqlStore *SqlStore) PostStore {
table.ColMap("Message").SetMaxSize(4000)
table.ColMap("Type").SetMaxSize(26)
table.ColMap("Hashtags").SetMaxSize(1000)
- table.ColMap("Props").SetMaxSize(4000)
+ table.ColMap("Props")
table.ColMap("Filenames").SetMaxSize(4000)
}
@@ -38,6 +40,21 @@ func NewSqlPostStore(sqlStore *SqlStore) PostStore {
}
func (s SqlPostStore) UpgradeSchemaIfNeeded() {
+ colType := s.GetColumnDataType("Posts", "Props")
+ if colType != "text" {
+
+ query := "ALTER TABLE Posts MODIFY COLUMN Props TEXT"
+ if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
+ query = "ALTER TABLE Posts ALTER COLUMN Props TYPE text"
+ }
+
+ _, err := s.GetMaster().Exec(query)
+ if err != nil {
+ l4g.Critical("Failed to alter column Posts.Props to TEXT: " + err.Error())
+ time.Sleep(time.Second)
+ panic("Failed to alter column Posts.Props to TEXT: " + err.Error())
+ }
+ }
}
func (s SqlPostStore) CreateIndexesIfNotExists() {
diff --git a/store/sql_store.go b/store/sql_store.go
index e5c540e06..f348db10b 100644
--- a/store/sql_store.go
+++ b/store/sql_store.go
@@ -455,6 +455,20 @@ func IsUniqueConstraintError(err string, mysql string, postgres string) bool {
return unique && field
}
+func (ss SqlStore) GetColumnDataType(tableName, columnName string) string {
+ dataType, err := ss.GetMaster().SelectStr("SELECT data_type FROM INFORMATION_SCHEMA.COLUMNS where table_name = :Tablename AND column_name = :Columnname", map[string]interface{}{
+ "Tablename": tableName,
+ "Columnname": columnName,
+ })
+ if err != nil {
+ l4g.Critical("Failed to get data type for column %s from table %s: %v", columnName, tableName, err.Error())
+ time.Sleep(time.Second)
+ panic("Failed to get get data type for column " + columnName + " from table " + tableName + ": " + err.Error())
+ }
+
+ return dataType
+}
+
func (ss SqlStore) GetMaster() *gorp.DbMap {
return ss.master
}
@@ -529,6 +543,8 @@ func (me mattermConverter) ToDb(val interface{}) (interface{}, error) {
return model.ArrayToJson(t), nil
case model.EncryptStringMap:
return encrypt([]byte(utils.Cfg.SqlSettings.AtRestEncryptKey), model.MapToJson(t))
+ case model.StringInterface:
+ return model.StringInterfaceToJson(t), nil
}
return val, nil
@@ -572,6 +588,16 @@ func (me mattermConverter) FromDb(target interface{}) (gorp.CustomScanner, bool)
return json.Unmarshal(b, target)
}
return gorp.CustomScanner{new(string), target, binder}, true
+ case *model.StringInterface:
+ binder := func(holder, target interface{}) error {
+ s, ok := holder.(*string)
+ if !ok {
+ return errors.New("FromDb: Unable to convert StringInterface to *string")
+ }
+ b := []byte(*s)
+ return json.Unmarshal(b, target)
+ }
+ return gorp.CustomScanner{new(string), target, binder}, true
}
return gorp.CustomScanner{}, false
diff --git a/store/sql_user_store.go b/store/sql_user_store.go
index 3347df08b..686949a4d 100644
--- a/store/sql_user_store.go
+++ b/store/sql_user_store.go
@@ -140,7 +140,9 @@ func (us SqlUserStore) Update(user *model.User, allowActiveUpdate bool) StoreCha
user.DeleteAt = oldUser.DeleteAt
}
- if user.Email != oldUser.Email {
+ if user.IsSSOUser() {
+ user.Email = oldUser.Email
+ } else if user.Email != oldUser.Email {
user.EmailVerified = false
}
diff --git a/web/react/components/edit_channel_purpose_modal.jsx b/web/react/components/edit_channel_purpose_modal.jsx
index 4cb96a3ff..65e8183de 100644
--- a/web/react/components/edit_channel_purpose_modal.jsx
+++ b/web/react/components/edit_channel_purpose_modal.jsx
@@ -3,6 +3,8 @@
const AsyncClient = require('../utils/async_client.jsx');
const Client = require('../utils/client.jsx');
+const Utils = require('../utils/utils.jsx');
+
const Modal = ReactBootstrap.Modal;
export default class EditChannelPurposeModal extends React.Component {
@@ -75,11 +77,6 @@ export default class EditChannelPurposeModal extends React.Component {
title = <span>{'Edit Purpose for '}<span className='name'>{this.props.channel.display_name}</span></span>;
}
- let channelTerm = 'Channel';
- if (this.props.channel.channelType === 'P') {
- channelTerm = 'Group';
- }
-
return (
<Modal
className='modal-edit-channel-purpose'
@@ -93,7 +90,7 @@ export default class EditChannelPurposeModal extends React.Component {
</Modal.Title>
</Modal.Header>
<Modal.Body>
- <p>{`Describe how this ${channelTerm} should be used.`}</p>
+ <p>{`Describe how this ${Utils.getChannelTerm(this.props.channel.channelType)} should be used.`}</p>
<textarea
ref='purpose'
className='form-control no-resize'
diff --git a/web/react/components/post_attachment.jsx b/web/react/components/post_attachment.jsx
new file mode 100644
index 000000000..2d6b47f03
--- /dev/null
+++ b/web/react/components/post_attachment.jsx
@@ -0,0 +1,295 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+const TextFormatting = require('../utils/text_formatting.jsx');
+
+export default class PostAttachment extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.getFieldsTable = this.getFieldsTable.bind(this);
+ this.getInitState = this.getInitState.bind(this);
+ this.shouldCollapse = this.shouldCollapse.bind(this);
+ this.toggleCollapseState = this.toggleCollapseState.bind(this);
+ }
+
+ componentDidMount() {
+ $(this.refs.attachment).on('click', '.attachment-link-more', this.toggleCollapseState);
+ }
+
+ componentWillUnmount() {
+ $(this.refs.attachment).off('click', '.attachment-link-more', this.toggleCollapseState);
+ }
+
+ componentWillMount() {
+ this.setState(this.getInitState());
+ }
+
+ getInitState() {
+ const shouldCollapse = this.shouldCollapse();
+ const text = TextFormatting.formatText(this.props.attachment.text || '');
+ const uncollapsedText = text + (shouldCollapse ? '<a class="attachment-link-more" href="#">▲ collapse text</a>' : '');
+ const collapsedText = shouldCollapse ? this.getCollapsedText() : text;
+
+ return {
+ shouldCollapse,
+ collapsedText,
+ uncollapsedText,
+ text: shouldCollapse ? collapsedText : uncollapsedText,
+ collapsed: shouldCollapse
+ };
+ }
+
+ toggleCollapseState(e) {
+ e.preventDefault();
+
+ let state = this.state;
+ state.text = state.collapsed ? state.uncollapsedText : state.collapsedText;
+ state.collapsed = !state.collapsed;
+ this.setState(state);
+ }
+
+ shouldCollapse() {
+ return (this.props.attachment.text.match(/\n/g) || []).length >= 5 || this.props.attachment.text.length > 700;
+ }
+
+ getCollapsedText() {
+ let text = this.props.attachment.text || '';
+ if ((text.match(/\n/g) || []).length >= 5) {
+ text = text.split('\n').splice(0, 5).join('\n');
+ } else if (text.length > 700) {
+ text = text.substr(0, 700);
+ }
+
+ return TextFormatting.formatText(text) + '<a class="attachment-link-more" href="#">▼ read more</a>';
+ }
+
+ getFieldsTable() {
+ const fields = this.props.attachment.fields;
+ if (!fields || !fields.length) {
+ return '';
+ }
+
+ const compactTable = fields.filter((field) => field.short).length > 0;
+ let tHead;
+ let tBody;
+
+ if (compactTable) {
+ let headerCols = [];
+ let bodyCols = [];
+
+ fields.forEach((field, i) => {
+ headerCols.push(
+ <th
+ className='attachment___field-caption'
+ key={'attachment__field-caption-' + i}
+ >
+ {field.title}
+ </th>
+ );
+ bodyCols.push(
+ <td
+ className='attachment___field'
+ key={'attachment__field-' + i}
+ dangerouslySetInnerHTML={{__html: TextFormatting.formatText(field.value || '')}}
+ >
+ </td>
+ );
+ });
+
+ tHead = (
+ <tr>
+ {headerCols}
+ </tr>
+ );
+ tBody = (
+ <tr>
+ {bodyCols}
+ </tr>
+ );
+ } else {
+ tBody = [];
+
+ fields.forEach((field, i) => {
+ tBody.push(
+ <tr key={'attachment__field-' + i}>
+ <td
+ className='attachment___field-caption'
+ >
+ {field.title}
+ </td>
+ <td
+ className='attachment___field'
+ dangerouslySetInnerHTML={{__html: TextFormatting.formatText(field.value || '')}}
+ >
+ </td>
+ </tr>
+ );
+ });
+ }
+
+ return (
+ <table
+ className='attachment___fields'
+ >
+ <thead>
+ {tHead}
+ </thead>
+ <tbody>
+ {tBody}
+ </tbody>
+ </table>
+ );
+ }
+
+ render() {
+ const data = this.props.attachment;
+
+ let preText;
+ if (data.pretext) {
+ preText = (
+ <div
+ className='attachment__thumb-pretext'
+ dangerouslySetInnerHTML={{__html: TextFormatting.formatText(data.pretext)}}
+ >
+ </div>
+ );
+ }
+
+ let author = [];
+ if (data.author_name || data.author_icon) {
+ if (data.author_icon) {
+ author.push(
+ <img
+ className='attachment__author-icon'
+ src={data.author_icon}
+ key={'attachment__author-icon'}
+ height='14'
+ width='14'
+ />
+ );
+ }
+ if (data.author_name) {
+ author.push(
+ <span
+ className='attachment__author-name'
+ key={'attachment__author-name'}
+ >
+ {data.author_name}
+ </span>
+ );
+ }
+ }
+ if (data.author_link) {
+ author = (
+ <a
+ href={data.author_link}
+ target='_blank'
+ >
+ {author}
+ </a>
+ );
+ }
+
+ let title;
+ if (data.title) {
+ if (data.title_link) {
+ title = (
+ <h1
+ className='attachment__title'
+ >
+ <a
+ className='attachment__title-link'
+ href={data.title_link}
+ target='_blank'
+ >
+ {data.title}
+ </a>
+ </h1>
+ );
+ } else {
+ title = (
+ <h1
+ className='attachment__title'
+ >
+ {data.title}
+ </h1>
+ );
+ }
+ }
+
+ let text;
+ if (data.text) {
+ text = (
+ <div
+ className='attachment__text'
+ dangerouslySetInnerHTML={{__html: this.state.text}}
+ >
+ </div>
+ );
+ }
+
+ let image;
+ if (data.image_url) {
+ image = (
+ <img
+ className='attachment__image'
+ src={data.image_url}
+ />
+ );
+ }
+
+ let thumb;
+ if (data.thumb_url) {
+ thumb = (
+ <div
+ className='attachment__thumb-container'
+ >
+ <img
+ src={data.thumb_url}
+ />
+ </div>
+ );
+ }
+
+ const fields = this.getFieldsTable();
+
+ let useBorderStyle;
+ if (data.color && data.color[0] === '#') {
+ useBorderStyle = {borderLeftColor: data.color};
+ }
+
+ return (
+ <div
+ className='attachment'
+ ref='attachment'
+ >
+ {preText}
+ <div className='attachment__content'>
+ <div
+ className={useBorderStyle ? 'clearfix attachment__container' : 'clearfix attachment__container attachment__container--' + data.color}
+ style={useBorderStyle}
+ >
+ {author}
+ {title}
+ <div>
+ <div
+ className={thumb ? 'attachment__body' : 'attachment__body attachment__body--no_thumb'}
+ >
+ {text}
+ {image}
+ {fields}
+ </div>
+ {thumb}
+ <div style={{clear: 'both'}}></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+PostAttachment.propTypes = {
+ attachment: React.PropTypes.object.isRequired
+}; \ No newline at end of file
diff --git a/web/react/components/post_attachment_list.jsx b/web/react/components/post_attachment_list.jsx
new file mode 100644
index 000000000..03b866656
--- /dev/null
+++ b/web/react/components/post_attachment_list.jsx
@@ -0,0 +1,32 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+const PostAttachment = require('./post_attachment.jsx');
+
+export default class PostAttachmentList extends React.Component {
+ constructor(props) {
+ super(props);
+ }
+
+ render() {
+ let content = [];
+ this.props.attachments.forEach((attachment, i) => {
+ content.push(
+ <PostAttachment
+ attachment={attachment}
+ key={'att_' + i}
+ />
+ );
+ });
+
+ return (
+ <div className='attachment_list'>
+ {content}
+ </div>
+ );
+ }
+}
+
+PostAttachmentList.propTypes = {
+ attachments: React.PropTypes.array.isRequired
+};
diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx
index e4094daf3..5a157b792 100644
--- a/web/react/components/post_body.jsx
+++ b/web/react/components/post_body.jsx
@@ -7,6 +7,7 @@ const Utils = require('../utils/utils.jsx');
const Constants = require('../utils/constants.jsx');
const TextFormatting = require('../utils/text_formatting.jsx');
const twemoji = require('twemoji');
+const PostBodyAdditionalContent = require('./post_body_additional_content.jsx');
export default class PostBody extends React.Component {
constructor(props) {
@@ -331,6 +332,9 @@ export default class PostBody extends React.Component {
dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.message)}}
/>
</div>
+ <PostBodyAdditionalContent
+ post={post}
+ />
{fileAttachmentHolder}
{embed}
</div>
diff --git a/web/react/components/post_body_additional_content.jsx b/web/react/components/post_body_additional_content.jsx
new file mode 100644
index 000000000..8189ba2d3
--- /dev/null
+++ b/web/react/components/post_body_additional_content.jsx
@@ -0,0 +1,56 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+const PostAttachmentList = require('./post_attachment_list.jsx');
+
+export default class PostBodyAdditionalContent extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.getSlackAttachment = this.getSlackAttachment.bind(this);
+ this.getComponent = this.getComponent.bind(this);
+ }
+
+ componentWillMount() {
+ this.setState({type: this.props.post.type, shouldRender: Boolean(this.props.post.type)});
+ }
+
+ getSlackAttachment() {
+ const attachments = this.props.post.props && this.props.post.props.attachments || [];
+ return (
+ <PostAttachmentList
+ key={'post_body_additional_content' + this.props.post.id}
+ attachments={attachments}
+ />
+ );
+ }
+
+ getComponent() {
+ switch (this.state.type) {
+ case 'slack_attachment':
+ return this.getSlackAttachment();
+ }
+ }
+
+ render() {
+ let content = [];
+
+ if (this.state.shouldRender) {
+ const component = this.getComponent();
+
+ if (component) {
+ content = component;
+ }
+ }
+
+ return (
+ <div>
+ {content}
+ </div>
+ );
+ }
+}
+
+PostBodyAdditionalContent.propTypes = {
+ post: React.PropTypes.object.isRequired
+}; \ No newline at end of file
diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx
index 03e14ec49..736919697 100644
--- a/web/react/components/search_autocomplete.jsx
+++ b/web/react/components/search_autocomplete.jsx
@@ -3,14 +3,15 @@
const ChannelStore = require('../stores/channel_store.jsx');
const KeyCodes = require('../utils/constants.jsx').KeyCodes;
+const Popover = ReactBootstrap.Popover;
const UserStore = require('../stores/user_store.jsx');
const Utils = require('../utils/utils.jsx');
+const Constants = require('../utils/constants.jsx');
const patterns = new Map([
['channels', /\b(?:in|channel):\s*(\S*)$/i],
['users', /\bfrom:\s*(\S*)$/i]
]);
-const Popover = ReactBootstrap.Popover;
export default class SearchAutocomplete extends React.Component {
constructor(props) {
@@ -22,8 +23,13 @@ export default class SearchAutocomplete extends React.Component {
this.handleKeyDown = this.handleKeyDown.bind(this);
this.completeWord = this.completeWord.bind(this);
+ this.getSelection = this.getSelection.bind(this);
+ this.scrollToItem = this.scrollToItem.bind(this);
this.updateSuggestions = this.updateSuggestions.bind(this);
+ this.renderChannelSuggestion = this.renderChannelSuggestion.bind(this);
+ this.renderUserSuggestion = this.renderUserSuggestion.bind(this);
+
this.state = {
show: false,
mode: '',
@@ -37,9 +43,18 @@ export default class SearchAutocomplete extends React.Component {
$(document).on('click', this.handleDocumentClick);
}
- componentDidUpdate() {
- $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content').perfectScrollbar();
- $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content').css('max-height', $(window).height() - 200);
+ componentDidUpdate(prevProps, prevState) {
+ const content = $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content');
+
+ if (this.state.show) {
+ if (!prevState.show) {
+ content.perfectScrollbar();
+ content.css('max-height', $(window).height() - 200);
+ }
+
+ // keep the keyboard selection visible when scrolling
+ this.scrollToItem(this.getSelection());
+ }
}
componentWillUnmount() {
@@ -51,7 +66,7 @@ export default class SearchAutocomplete extends React.Component {
}
handleDocumentClick(e) {
- const container = $(ReactDOM.findDOMNode(this.refs.container));
+ const container = $(ReactDOM.findDOMNode(this.refs.searchPopover));
if (!(container.is(e.target) || container.has(e.target).length > 0)) {
this.setState({
@@ -111,15 +126,7 @@ export default class SearchAutocomplete extends React.Component {
} else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.SPACE) {
e.preventDefault();
- this.completeSelectedWord();
- }
- }
-
- completeSelectedWord() {
- if (this.state.mode === 'channels') {
- this.completeWord(this.state.suggestions[this.state.selection].name);
- } else if (this.state.mode === 'users') {
- this.completeWord(this.state.suggestions[this.state.selection].username);
+ this.completeWord(this.getSelection());
}
}
@@ -135,6 +142,40 @@ export default class SearchAutocomplete extends React.Component {
});
}
+ getSelection() {
+ if (this.state.mode === 'channels') {
+ return this.state.suggestions[this.state.selection].name;
+ } else if (this.state.mode === 'users') {
+ return this.state.suggestions[this.state.selection].username;
+ }
+
+ return '';
+ }
+
+ scrollToItem(itemName) {
+ const content = $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content');
+ const visibleContentHeight = content[0].clientHeight;
+ const actualContentHeight = content[0].scrollHeight;
+
+ if (this.state.suggestions.length > 0 && visibleContentHeight < actualContentHeight) {
+ const contentTop = content.scrollTop();
+ const contentTopPadding = parseInt(content.css('padding-top'), 10);
+ const contentBottomPadding = parseInt(content.css('padding-top'), 10);
+
+ const item = $(this.refs[itemName]);
+ const itemTop = item[0].offsetTop - parseInt(item.css('margin-top'), 10);
+ const itemBottom = item[0].offsetTop + item.height() + parseInt(item.css('margin-bottom'), 10);
+
+ if (itemTop - contentTopPadding < contentTop) {
+ // the item is off the top of the visible space
+ content.scrollTop(itemTop - contentTopPadding);
+ } else if (itemBottom + contentTopPadding + contentBottomPadding > contentTop + visibleContentHeight) {
+ // the item has gone off the bottom of the visible space
+ content.scrollTop(itemBottom - visibleContentHeight + contentTopPadding + contentBottomPadding);
+ }
+ }
+ }
+
updateSuggestions(mode, filter) {
let suggestions = [];
@@ -193,6 +234,46 @@ export default class SearchAutocomplete extends React.Component {
});
}
+ renderChannelSuggestion(channel) {
+ let className = 'search-autocomplete__item';
+ if (channel.name === this.getSelection()) {
+ className += ' selected';
+ }
+
+ return (
+ <div
+ key={channel.name}
+ ref={channel.name}
+ onClick={this.handleClick.bind(this, channel.name)}
+ className={className}
+ >
+ {channel.name}
+ </div>
+ );
+ }
+
+ renderUserSuggestion(user) {
+ let className = 'search-autocomplete__item';
+ if (user.username === this.getSelection()) {
+ className += ' selected';
+ }
+
+ return (
+ <div
+ key={user.username}
+ ref={user.username}
+ onClick={this.handleClick.bind(this, user.username)}
+ className={className}
+ >
+ <img
+ className='profile-img rounded'
+ src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at}
+ />
+ {user.username}
+ </div>
+ );
+ }
+
render() {
if (!this.state.show || this.state.suggestions.length === 0) {
return null;
@@ -201,45 +282,33 @@ export default class SearchAutocomplete extends React.Component {
let suggestions = [];
if (this.state.mode === 'channels') {
- suggestions = this.state.suggestions.map((channel, index) => {
- let className = 'search-autocomplete__item';
- if (this.state.selection === index) {
- className += ' selected';
- }
-
- return (
+ const publicChannels = this.state.suggestions.filter((channel) => channel.type === Constants.OPEN_CHANNEL);
+ if (publicChannels.length > 0) {
+ suggestions.push(
<div
- key={channel.name}
- ref={channel.name}
- onClick={this.handleClick.bind(this, channel.name)}
- className={className}
+ key='public-channel-divider'
+ className='search-autocomplete__divider'
>
- {channel.name}
+ {'Public ' + Utils.getChannelTerm(Constants.OPEN_CHANNEL) + 's'}
</div>
);
- });
- } else if (this.state.mode === 'users') {
- suggestions = this.state.suggestions.map((user, index) => {
- let className = 'search-autocomplete__item';
- if (this.state.selection === index) {
- className += ' selected';
- }
+ suggestions = suggestions.concat(publicChannels.map(this.renderChannelSuggestion));
+ }
- return (
+ const privateChannels = this.state.suggestions.filter((channel) => channel.type === Constants.PRIVATE_CHANNEL);
+ if (privateChannels.length > 0) {
+ suggestions.push(
<div
- key={user.username}
- ref={user.username}
- onClick={this.handleClick.bind(this, user.username)}
- className={className}
+ key='private-channel-divider'
+ className='search-autocomplete__divider'
>
- <img
- className='profile-img rounded'
- src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at}
- />
- {user.username}
+ {'Private ' + Utils.getChannelTerm(Constants.PRIVATE_CHANNEL) + 's'}
</div>
);
- });
+ suggestions = suggestions.concat(privateChannels.map(this.renderChannelSuggestion));
+ }
+ } else if (this.state.mode === 'users') {
+ suggestions = this.state.suggestions.map(this.renderUserSuggestion);
}
return (
diff --git a/web/react/components/tutorial/tutorial_intro_screens.jsx b/web/react/components/tutorial/tutorial_intro_screens.jsx
index a99e9fe28..66ca556c6 100644
--- a/web/react/components/tutorial/tutorial_intro_screens.jsx
+++ b/web/react/components/tutorial/tutorial_intro_screens.jsx
@@ -11,12 +11,15 @@ const AsyncClient = require('../../utils/async_client.jsx');
const Constants = require('../../utils/constants.jsx');
const Preferences = Constants.Preferences;
+const NUM_SCREENS = 3;
+
export default class TutorialIntroScreens extends React.Component {
constructor(props) {
super(props);
this.handleNext = this.handleNext.bind(this);
this.createScreen = this.createScreen.bind(this);
+ this.createCircles = this.createCircles.bind(this);
this.state = {currentScreen: 0};
}
@@ -49,31 +52,27 @@ export default class TutorialIntroScreens extends React.Component {
}
}
createScreenOne() {
+ const circles = this.createCircles();
+
return (
<div>
<h3>{'Welcome to:'}</h3>
<h1>{'Mattermost'}</h1>
<p>{'Your team communication all in one place, instantly searchable and available anywhere.'}</p>
<p>{'Keep your team connected to help them achieve what matters most.'}</p>
- <div className='tutorial__circles'>
- <div className='circle active'/>
- <div className='circle'/>
- <div className='circle'/>
- </div>
+ {circles}
</div>
);
}
createScreenTwo() {
+ const circles = this.createCircles();
+
return (
<div>
<h3>{'How Mattermost works:'}</h3>
<p>{'Communication happens in public discussion channels, private groups and direct messages.'}</p>
<p>{'Everything is archived and searchable from any web-enabled desktop, laptop or phone.'}</p>
- <div className='tutorial__circles'>
- <div className='circle'/>
- <div className='circle active'/>
- <div className='circle'/>
- </div>
+ {circles}
</div>
);
}
@@ -106,6 +105,8 @@ export default class TutorialIntroScreens extends React.Component {
);
}
+ const circles = this.createCircles();
+
return (
<div>
<h3>{'You’re all set'}</h3>
@@ -124,11 +125,34 @@ export default class TutorialIntroScreens extends React.Component {
{'.'}
</p>
{'Click “Next” to enter Town Square. This is the first channel teammates see when they sign up. Use it for posting updates everyone needs to know.'}
- <div className='tutorial__circles'>
- <div className='circle'/>
- <div className='circle'/>
- <div className='circle active'/>
- </div>
+ {circles}
+ </div>
+ );
+ }
+ createCircles() {
+ const circles = [];
+ for (let i = 0; i < NUM_SCREENS; i++) {
+ let className = 'circle';
+ if (i === this.state.currentScreen) {
+ className += ' active';
+ }
+
+ circles.push(
+ <a
+ href='#'
+ key={'circle' + i}
+ className={className}
+ onClick={(e) => { //eslint-disable-line no-loop-func
+ e.preventDefault();
+ this.setState({currentScreen: i});
+ }}
+ />
+ );
+ }
+
+ return (
+ <div className='tutorial__circles'>
+ {circles}
</div>
);
}
diff --git a/web/react/components/user_profile.jsx b/web/react/components/user_profile.jsx
index c4402ae23..eb0a8f0ca 100644
--- a/web/react/components/user_profile.jsx
+++ b/web/react/components/user_profile.jsx
@@ -41,7 +41,7 @@ export default class UserProfile extends React.Component {
UserStore.removeChangeListener(this.onChange);
}
onChange(userId) {
- if (userId === this.props.userId) {
+ if (!userId || userId === this.props.userId) {
var newState = this.getStateFromStores(this.props.userId);
if (!Utils.areStatesEqual(newState, this.state)) {
this.setState(newState);
diff --git a/web/react/components/user_settings/user_settings_general.jsx b/web/react/components/user_settings/user_settings_general.jsx
index 9f0c16194..1bfae6930 100644
--- a/web/react/components/user_settings/user_settings_general.jsx
+++ b/web/react/components/user_settings/user_settings_general.jsx
@@ -451,44 +451,60 @@ export default class UserSettingsGeneralTab extends React.Component {
}
}
- inputs.push(
- <div key='emailSetting'>
- <div className='form-group'>
- <label className='col-sm-5 control-label'>{'Primary Email'}</label>
- <div className='col-sm-7'>
- <input
- className='form-control'
- type='text'
- onChange={this.updateEmail}
- value={this.state.email}
- />
+ let submit = null;
+
+ if (this.props.user.auth_service === '') {
+ inputs.push(
+ <div key='emailSetting'>
+ <div className='form-group'>
+ <label className='col-sm-5 control-label'>{'Primary Email'}</label>
+ <div className='col-sm-7'>
+ <input
+ className='form-control'
+ type='text'
+ onChange={this.updateEmail}
+ value={this.state.email}
+ />
+ </div>
</div>
</div>
- </div>
- );
-
- inputs.push(
- <div key='confirmEmailSetting'>
- <div className='form-group'>
- <label className='col-sm-5 control-label'>{'Confirm Email'}</label>
- <div className='col-sm-7'>
- <input
- className='form-control'
- type='text'
- onChange={this.updateConfirmEmail}
- value={this.state.confirmEmail}
- />
+ );
+
+ inputs.push(
+ <div key='confirmEmailSetting'>
+ <div className='form-group'>
+ <label className='col-sm-5 control-label'>{'Confirm Email'}</label>
+ <div className='col-sm-7'>
+ <input
+ className='form-control'
+ type='text'
+ onChange={this.updateConfirmEmail}
+ value={this.state.confirmEmail}
+ />
+ </div>
</div>
+ {helpText}
</div>
- {helpText}
- </div>
- );
+ );
+
+ submit = this.submitEmail;
+ } else {
+ inputs.push(
+ <div
+ key='oauthEmailInfo'
+ className='form-group'
+ >
+ <div className='setting-list__hint'>{'Log in occurs through GitLab. Email cannot be updated.'}</div>
+ {helpText}
+ </div>
+ );
+ }
emailSection = (
<SettingItemMax
title='Email'
inputs={inputs}
- submit={this.submitEmail}
+ submit={submit}
server_error={serverError}
client_error={emailError}
updateSection={function clearSection(e) {
@@ -499,15 +515,19 @@ export default class UserSettingsGeneralTab extends React.Component {
);
} else {
let describe = '';
- if (this.state.emailChangeInProgress) {
- const newEmail = UserStore.getCurrentUser().email;
- if (newEmail) {
- describe = 'New Address: ' + newEmail + '\nCheck your email to verify the above address.';
+ if (this.props.user.auth_service === '') {
+ if (this.state.emailChangeInProgress) {
+ const newEmail = UserStore.getCurrentUser().email;
+ if (newEmail) {
+ describe = 'New Address: ' + newEmail + '\nCheck your email to verify the above address.';
+ } else {
+ describe = 'Check your email to verify your new address';
+ }
} else {
- describe = 'Check your email to verify your new address';
+ describe = UserStore.getCurrentUser().email;
}
} else {
- describe = UserStore.getCurrentUser().email;
+ describe = 'Log in done through GitLab';
}
emailSection = (
diff --git a/web/react/stores/browser_store.jsx b/web/react/stores/browser_store.jsx
index 75fb8aa3c..8e86ce32f 100644
--- a/web/react/stores/browser_store.jsx
+++ b/web/react/stores/browser_store.jsx
@@ -24,11 +24,17 @@ class BrowserStoreClass {
this.setLastServerVersion = this.setLastServerVersion.bind(this);
this.clear = this.clear.bind(this);
this.clearAll = this.clearAll.bind(this);
+ this.checkedLocalStorageSupported = '';
+ this.signalLogout = this.signalLogout.bind(this);
var currentVersion = sessionStorage.getItem('storage_version');
if (currentVersion !== global.window.mm_config.Version) {
sessionStorage.clear();
- sessionStorage.setItem('storage_version', global.window.mm_config.Version);
+ try {
+ sessionStorage.setItem('storage_version', global.window.mm_config.Version);
+ } catch (e) {
+ // Do nothing
+ }
}
}
@@ -105,6 +111,13 @@ class BrowserStoreClass {
sessionStorage.setItem('last_server_version', version);
}
+ signalLogout() {
+ if (this.isLocalStorageSupported()) {
+ localStorage.setItem('__logout__', 'yes');
+ localStorage.removeItem('__logout__');
+ }
+ }
+
/**
* Preforms the given action on each item that has the given prefix
* Signature for action is action(key, value)
@@ -147,20 +160,26 @@ class BrowserStoreClass {
}
isLocalStorageSupported() {
+ if (this.checkedLocalStorageSupported !== '') {
+ return this.checkedLocalStorageSupported;
+ }
+
try {
- sessionStorage.setItem('testSession', '1');
- sessionStorage.removeItem('testSession');
+ sessionStorage.setItem('__testSession__', '1');
+ sessionStorage.removeItem('__testSession__');
- localStorage.setItem('testLocal', '1');
- if (localStorage.getItem('testLocal') !== '1') {
- return false;
+ localStorage.setItem('__testLocal__', '1');
+ if (localStorage.getItem('__testLocal__') !== '1') {
+ this.checkedLocalStorageSupported = false;
}
- localStorage.removeItem('testLocal', '1');
+ localStorage.removeItem('__testLocal__', '1');
- return true;
+ this.checkedLocalStorageSupported = true;
} catch (e) {
- return false;
+ this.checkedLocalStorageSupported = false;
}
+
+ return this.checkedLocalStorageSupported;
}
}
diff --git a/web/react/stores/user_store.jsx b/web/react/stores/user_store.jsx
index 4fa7224b7..6b7d671fc 100644
--- a/web/react/stores/user_store.jsx
+++ b/web/react/stores/user_store.jsx
@@ -58,6 +58,8 @@ class UserStoreClass extends EventEmitter {
this.setStatus = this.setStatus.bind(this);
this.getStatuses = this.getStatuses.bind(this);
this.getStatus = this.getStatus.bind(this);
+
+ this.profileCache = null;
}
emitChange(userId) {
@@ -184,6 +186,10 @@ class UserStoreClass extends EventEmitter {
}
getProfiles() {
+ if (this.profileCache !== null) {
+ return this.profileCache;
+ }
+
return BrowserStore.getItem('profiles', {});
}
@@ -218,6 +224,7 @@ class UserStoreClass extends EventEmitter {
saveProfile(profile) {
var ps = this.getProfiles();
ps[profile.id] = profile;
+ this.profileCache = ps;
BrowserStore.setItem('profiles', ps);
}
@@ -226,6 +233,8 @@ class UserStoreClass extends EventEmitter {
if (currentId in profiles) {
delete profiles[currentId];
}
+
+ this.profileCache = profiles;
BrowserStore.setItem('profiles', profiles);
}
diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx
index 003e24d33..d27fe16cf 100644
--- a/web/react/utils/client.jsx
+++ b/web/react/utils/client.jsx
@@ -231,6 +231,7 @@ export function resetPassword(data, success, error) {
export function logout() {
track('api', 'api_users_logout');
var currentTeamUrl = TeamStore.getCurrentTeamUrl();
+ BrowserStore.signalLogout();
BrowserStore.clear();
ErrorStore.storeLastError(null);
window.location.href = currentTeamUrl + '/logout';
diff --git a/web/react/utils/highlight.jsx b/web/react/utils/highlight.jsx
new file mode 100644
index 000000000..68fef7930
--- /dev/null
+++ b/web/react/utils/highlight.jsx
@@ -0,0 +1,51 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+const highlightJs = require('highlight.js/lib/highlight.js');
+const highlightJsDiff = require('highlight.js/lib/languages/diff.js');
+const highlightJsApache = require('highlight.js/lib/languages/apache.js');
+const highlightJsMakefile = require('highlight.js/lib/languages/makefile.js');
+const highlightJsHttp = require('highlight.js/lib/languages/http.js');
+const highlightJsJson = require('highlight.js/lib/languages/json.js');
+const highlightJsMarkdown = require('highlight.js/lib/languages/markdown.js');
+const highlightJsJavascript = require('highlight.js/lib/languages/javascript.js');
+const highlightJsCss = require('highlight.js/lib/languages/css.js');
+const highlightJsNginx = require('highlight.js/lib/languages/nginx.js');
+const highlightJsObjectivec = require('highlight.js/lib/languages/objectivec.js');
+const highlightJsPython = require('highlight.js/lib/languages/python.js');
+const highlightJsXml = require('highlight.js/lib/languages/xml.js');
+const highlightJsPerl = require('highlight.js/lib/languages/perl.js');
+const highlightJsBash = require('highlight.js/lib/languages/bash.js');
+const highlightJsPhp = require('highlight.js/lib/languages/php.js');
+const highlightJsCoffeescript = require('highlight.js/lib/languages/coffeescript.js');
+const highlightJsCs = require('highlight.js/lib/languages/cs.js');
+const highlightJsCpp = require('highlight.js/lib/languages/cpp.js');
+const highlightJsSql = require('highlight.js/lib/languages/sql.js');
+const highlightJsGo = require('highlight.js/lib/languages/go.js');
+const highlightJsRuby = require('highlight.js/lib/languages/ruby.js');
+const highlightJsJava = require('highlight.js/lib/languages/java.js');
+const highlightJsIni = require('highlight.js/lib/languages/ini.js');
+
+highlightJs.registerLanguage('diff', highlightJsDiff);
+highlightJs.registerLanguage('apache', highlightJsApache);
+highlightJs.registerLanguage('makefile', highlightJsMakefile);
+highlightJs.registerLanguage('http', highlightJsHttp);
+highlightJs.registerLanguage('json', highlightJsJson);
+highlightJs.registerLanguage('markdown', highlightJsMarkdown);
+highlightJs.registerLanguage('javascript', highlightJsJavascript);
+highlightJs.registerLanguage('css', highlightJsCss);
+highlightJs.registerLanguage('nginx', highlightJsNginx);
+highlightJs.registerLanguage('objectivec', highlightJsObjectivec);
+highlightJs.registerLanguage('python', highlightJsPython);
+highlightJs.registerLanguage('xml', highlightJsXml);
+highlightJs.registerLanguage('perl', highlightJsPerl);
+highlightJs.registerLanguage('bash', highlightJsBash);
+highlightJs.registerLanguage('php', highlightJsPhp);
+highlightJs.registerLanguage('coffeescript', highlightJsCoffeescript);
+highlightJs.registerLanguage('cs', highlightJsCs);
+highlightJs.registerLanguage('cpp', highlightJsCpp);
+highlightJs.registerLanguage('sql', highlightJsSql);
+highlightJs.registerLanguage('go', highlightJsGo);
+highlightJs.registerLanguage('ruby', highlightJsRuby);
+highlightJs.registerLanguage('java', highlightJsJava);
+highlightJs.registerLanguage('ini', highlightJsIni);
diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx
index 3ef09211f..374caf6dc 100644
--- a/web/react/utils/markdown.jsx
+++ b/web/react/utils/markdown.jsx
@@ -1,38 +1,14 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+require('./highlight.jsx');
const TextFormatting = require('./text_formatting.jsx');
const Utils = require('./utils.jsx');
+const highlightJs = require('highlight.js/lib/highlight.js');
const marked = require('marked');
-const highlightJs = require('highlight.js/lib/highlight.js');
-const highlightJsDiff = require('highlight.js/lib/languages/diff.js');
-const highlightJsApache = require('highlight.js/lib/languages/apache.js');
-const highlightJsMakefile = require('highlight.js/lib/languages/makefile.js');
-const highlightJsHttp = require('highlight.js/lib/languages/http.js');
-const highlightJsJson = require('highlight.js/lib/languages/json.js');
-const highlightJsMarkdown = require('highlight.js/lib/languages/markdown.js');
-const highlightJsJavascript = require('highlight.js/lib/languages/javascript.js');
-const highlightJsCss = require('highlight.js/lib/languages/css.js');
-const highlightJsNginx = require('highlight.js/lib/languages/nginx.js');
-const highlightJsObjectivec = require('highlight.js/lib/languages/objectivec.js');
-const highlightJsPython = require('highlight.js/lib/languages/python.js');
-const highlightJsXml = require('highlight.js/lib/languages/xml.js');
-const highlightJsPerl = require('highlight.js/lib/languages/perl.js');
-const highlightJsBash = require('highlight.js/lib/languages/bash.js');
-const highlightJsPhp = require('highlight.js/lib/languages/php.js');
-const highlightJsCoffeescript = require('highlight.js/lib/languages/coffeescript.js');
-const highlightJsCs = require('highlight.js/lib/languages/cs.js');
-const highlightJsCpp = require('highlight.js/lib/languages/cpp.js');
-const highlightJsSql = require('highlight.js/lib/languages/sql.js');
-const highlightJsGo = require('highlight.js/lib/languages/go.js');
-const highlightJsRuby = require('highlight.js/lib/languages/ruby.js');
-const highlightJsJava = require('highlight.js/lib/languages/java.js');
-const highlightJsIni = require('highlight.js/lib/languages/ini.js');
-
-const Constants = require('../utils/constants.jsx');
-const HighlightedLanguages = Constants.HighlightedLanguages;
+const HighlightedLanguages = require('../utils/constants.jsx').HighlightedLanguages;
function markdownImageLoaded(image) {
image.style.height = 'auto';
@@ -84,30 +60,6 @@ class MattermostMarkdownRenderer extends marked.Renderer {
this.text = this.text.bind(this);
this.formattingOptions = formattingOptions;
-
- highlightJs.registerLanguage('diff', highlightJsDiff);
- highlightJs.registerLanguage('apache', highlightJsApache);
- highlightJs.registerLanguage('makefile', highlightJsMakefile);
- highlightJs.registerLanguage('http', highlightJsHttp);
- highlightJs.registerLanguage('json', highlightJsJson);
- highlightJs.registerLanguage('markdown', highlightJsMarkdown);
- highlightJs.registerLanguage('javascript', highlightJsJavascript);
- highlightJs.registerLanguage('css', highlightJsCss);
- highlightJs.registerLanguage('nginx', highlightJsNginx);
- highlightJs.registerLanguage('objectivec', highlightJsObjectivec);
- highlightJs.registerLanguage('python', highlightJsPython);
- highlightJs.registerLanguage('xml', highlightJsXml);
- highlightJs.registerLanguage('perl', highlightJsPerl);
- highlightJs.registerLanguage('bash', highlightJsBash);
- highlightJs.registerLanguage('php', highlightJsPhp);
- highlightJs.registerLanguage('coffeescript', highlightJsCoffeescript);
- highlightJs.registerLanguage('cs', highlightJsCs);
- highlightJs.registerLanguage('cpp', highlightJsCpp);
- highlightJs.registerLanguage('sql', highlightJsSql);
- highlightJs.registerLanguage('go', highlightJsGo);
- highlightJs.registerLanguage('ruby', highlightJsRuby);
- highlightJs.registerLanguage('java', highlightJsJava);
- highlightJs.registerLanguage('ini', highlightJsIni);
}
code(code, language) {
@@ -204,6 +156,301 @@ class MattermostMarkdownRenderer extends marked.Renderer {
}
}
+class MattermostLexer extends marked.Lexer {
+ token(originalSrc, top, bq) {
+ let src = originalSrc.replace(/^ +$/gm, '');
+
+ while (src) {
+ // newline
+ let cap = this.rules.newline.exec(src);
+ if (cap) {
+ src = src.substring(cap[0].length);
+ if (cap[0].length > 1) {
+ this.tokens.push({
+ type: 'space'
+ });
+ }
+ }
+
+ // code
+ cap = this.rules.code.exec(src);
+ if (cap) {
+ src = src.substring(cap[0].length);
+ cap = cap[0].replace(/^ {4}/gm, '');
+ this.tokens.push({
+ type: 'code',
+ text: this.options.pedantic ? cap : cap.replace(/\n+$/, '')
+ });
+ continue;
+ }
+
+ // fences (gfm)
+ cap = this.rules.fences.exec(src);
+ if (cap) {
+ src = src.substring(cap[0].length);
+ this.tokens.push({
+ type: 'code',
+ lang: cap[2],
+ text: cap[3] || ''
+ });
+ continue;
+ }
+
+ // heading
+ cap = this.rules.heading.exec(src);
+ if (cap) {
+ src = src.substring(cap[0].length);
+ this.tokens.push({
+ type: 'heading',
+ depth: cap[1].length,
+ text: cap[2]
+ });
+ continue;
+ }
+
+ // table no leading pipe (gfm)
+ cap = this.rules.nptable.exec(src);
+ if (top && cap) {
+ src = src.substring(cap[0].length);
+
+ const item = {
+ type: 'table',
+ header: cap[1].replace(/^ *| *\| *$/g, '').split(/ *\| */),
+ align: cap[2].replace(/^ *|\| *$/g, '').split(/ *\| */),
+ cells: cap[3].replace(/\n$/, '').split('\n')
+ };
+
+ for (let i = 0; i < item.align.length; i++) {
+ if (/^ *-+: *$/.test(item.align[i])) {
+ item.align[i] = 'right';
+ } else if (/^ *:-+: *$/.test(item.align[i])) {
+ item.align[i] = 'center';
+ } else if (/^ *:-+ *$/.test(item.align[i])) {
+ item.align[i] = 'left';
+ } else {
+ item.align[i] = null;
+ }
+ }
+
+ for (let i = 0; i < item.cells.length; i++) {
+ item.cells[i] = item.cells[i].split(/ *\| */);
+ }
+
+ this.tokens.push(item);
+
+ continue;
+ }
+
+ // lheading
+ cap = this.rules.lheading.exec(src);
+ if (cap) {
+ src = src.substring(cap[0].length);
+ this.tokens.push({
+ type: 'heading',
+ depth: cap[2] === '=' ? 1 : 2,
+ text: cap[1]
+ });
+ continue;
+ }
+
+ // hr
+ cap = this.rules.hr.exec(src);
+ if (cap) {
+ src = src.substring(cap[0].length);
+ this.tokens.push({
+ type: 'hr'
+ });
+ continue;
+ }
+
+ // blockquote
+ cap = this.rules.blockquote.exec(src);
+ if (cap) {
+ src = src.substring(cap[0].length);
+
+ this.tokens.push({
+ type: 'blockquote_start'
+ });
+
+ cap = cap[0].replace(/^ *> ?/gm, '');
+
+ // Pass `top` to keep the current
+ // "toplevel" state. This is exactly
+ // how markdown.pl works.
+ this.token(cap, top, true);
+
+ this.tokens.push({
+ type: 'blockquote_end'
+ });
+
+ continue;
+ }
+
+ // list
+ cap = this.rules.list.exec(src);
+ if (cap) {
+ const bull = cap[2];
+ let l = cap[0].length;
+
+ // Get each top-level item.
+ cap = cap[0].match(this.rules.item);
+
+ if (cap.length > 1) {
+ src = src.substring(l);
+
+ this.tokens.push({
+ type: 'list_start',
+ ordered: bull.length > 1
+ });
+
+ let next = false;
+ l = cap.length;
+
+ for (let i = 0; i < l; i++) {
+ let item = cap[i];
+
+ // Remove the list item's bullet
+ // so it is seen as the next token.
+ let space = item.length;
+ item = item.replace(/^ *([*+-]|\d+\.) +/, '');
+
+ // Outdent whatever the
+ // list item contains. Hacky.
+ if (~item.indexOf('\n ')) {
+ space -= item.length;
+ item = this.options.pedantic ? item.replace(/^ {1,4}/gm, '') : item.replace(new RegExp('^ \{1,' + space + '\}', 'gm'), '');
+ }
+
+ // Determine whether the next list item belongs here.
+ // Backpedal if it does not belong in this list.
+ if (this.options.smartLists && i !== l - 1) {
+ const bullet = /(?:[*+-]|\d+\.)/;
+ const b = bullet.exec(cap[i + 1])[0];
+ if (bull !== b && !(bull.length > 1 && b.length > 1)) {
+ src = cap.slice(i + 1).join('\n') + src;
+ i = l - 1;
+ }
+ }
+
+ // Determine whether item is loose or not.
+ // Use: /(^|\n)(?! )[^\n]+\n\n(?!\s*$)/
+ // for discount behavior.
+ let loose = next || (/\n\n(?!\s*$)/).test(item);
+ if (i !== l - 1) {
+ next = item.charAt(item.length - 1) === '\n';
+ if (!loose) {
+ loose = next;
+ }
+ }
+
+ this.tokens.push({
+ type: loose ? 'loose_item_start' : 'list_item_start'
+ });
+
+ // Recurse.
+ this.token(item, false, bq);
+
+ this.tokens.push({
+ type: 'list_item_end'
+ });
+ }
+
+ this.tokens.push({
+ type: 'list_end'
+ });
+
+ continue;
+ }
+ }
+
+ // html
+ cap = this.rules.html.exec(src);
+ if (cap) {
+ src = src.substring(cap[0].length);
+ this.tokens.push({
+ type: this.options.sanitize ? 'paragraph' : 'html',
+ pre: !this.options.sanitizer && (cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style'),
+ text: cap[0]
+ });
+ continue;
+ }
+
+ // def
+ cap = this.rules.def.exec(src);
+ if ((!bq && top) && cap) {
+ src = src.substring(cap[0].length);
+ this.tokens.links[cap[1].toLowerCase()] = {
+ href: cap[2],
+ title: cap[3]
+ };
+ continue;
+ }
+
+ // table (gfm)
+ cap = this.rules.table.exec(src);
+ if (top && cap) {
+ src = src.substring(cap[0].length);
+
+ const item = {
+ type: 'table',
+ header: cap[1].replace(/^ *| *\| *$/g, '').split(/ *\| */),
+ align: cap[2].replace(/^ *|\| *$/g, '').split(/ *\| */),
+ cells: cap[3].replace(/(?: *\| *)?\n$/, '').split('\n')
+ };
+
+ for (let i = 0; i < item.align.length; i++) {
+ if (/^ *-+: *$/.test(item.align[i])) {
+ item.align[i] = 'right';
+ } else if (/^ *:-+: *$/.test(item.align[i])) {
+ item.align[i] = 'center';
+ } else if (/^ *:-+ *$/.test(item.align[i])) {
+ item.align[i] = 'left';
+ } else {
+ item.align[i] = null;
+ }
+ }
+
+ for (let i = 0; i < item.cells.length; i++) {
+ item.cells[i] = item.cells[i].replace(/^ *\| *| *\| *$/g, '').split(/ *\| */);
+ }
+
+ this.tokens.push(item);
+
+ continue;
+ }
+
+ // top-level paragraph
+ cap = this.rules.paragraph.exec(src);
+ if (top && cap) {
+ src = src.substring(cap[0].length);
+ this.tokens.push({
+ type: 'paragraph',
+ text: cap[1].charAt(cap[1].length - 1) === '\n' ? cap[1].slice(0, -1) : cap[1]
+ });
+ continue;
+ }
+
+ // text
+ cap = this.rules.text.exec(src);
+ if (cap) {
+ // Top-level should never reach here.
+ src = src.substring(cap[0].length);
+ this.tokens.push({
+ type: 'text',
+ text: cap[0]
+ });
+ continue;
+ }
+
+ if (src) {
+ throw new Error('Infinite loop on byte: ' + src.charCodeAt(0));
+ }
+ }
+
+ return this.tokens;
+ }
+}
+
export function format(text, options) {
const markdownOptions = {
renderer: new MattermostMarkdownRenderer(null, options),
@@ -212,7 +459,7 @@ export function format(text, options) {
tables: true
};
- const tokens = marked.lexer(text, markdownOptions);
+ const tokens = new MattermostLexer(markdownOptions).lex(text);
return new MattermostParser(markdownOptions).parse(tokens);
}
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index fff9c460b..8052c000c 100644
--- a/web/react/utils/utils.jsx
+++ b/web/react/utils/utils.jsx
@@ -155,7 +155,7 @@ var canDing = true;
export function ding() {
if (!isBrowserFirefox() && canDing) {
- var audio = new Audio('/static/images/ding.mp3');
+ var audio = new Audio('/static/images/bing.mp3');
audio.play();
canDing = false;
setTimeout(() => canDing = true, 3000);
@@ -481,6 +481,7 @@ export function applyTheme(theme) {
changeCss('.modal .modal-header', 'background:' + theme.sidebarHeaderBg, 1);
changeCss('#navbar .navbar-default', 'background:' + theme.sidebarHeaderBg, 1);
changeCss('@media(max-width: 768px){.search-bar__container', 'background:' + theme.sidebarHeaderBg, 1);
+ changeCss('.attachment .attachment__container', 'border-left-color:' + theme.sidebarHeaderBg, 1);
}
if (theme.sidebarHeaderTextColor) {
@@ -519,6 +520,7 @@ export function applyTheme(theme) {
changeCss('.popover.left>.arrow:after', 'border-left-color:' + theme.centerChannelBg, 1);
changeCss('.popover.top>.arrow:after, .tip-overlay.tip-overlay--chat .arrow', 'border-top-color:' + theme.centerChannelBg, 1);
changeCss('.search-bar__container .search__form .search-bar, .form-control', 'background:' + theme.centerChannelBg, 1);
+ changeCss('.attachment__content', 'background:' + theme.centerChannelBg, 1);
}
if (theme.centerChannelColor) {
@@ -552,6 +554,7 @@ export function applyTheme(theme) {
changeCss('@media(max-width: 768px){.search-bar__container .search__form .search-bar', 'background:' + changeOpacity(theme.centerChannelColor, 0.2) + '; color: inherit;', 1);
changeCss('.input-group-addon, .search-bar__container .search__form, .form-control', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1);
changeCss('.form-control:focus', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1);
+ changeCss('.attachment .attachment__content', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1);
changeCss('.channel-intro .channel-intro__content, .webhooks__container', 'background:' + changeOpacity(theme.centerChannelColor, 0.05), 1);
changeCss('.date-separator .separator__text', 'color:' + theme.centerChannelColor, 2);
changeCss('.date-separator .separator__hr, .modal-footer, .modal .custom-textarea, .post-right__container .post.post--root hr, .search-item-container', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1);
@@ -1129,3 +1132,12 @@ export function sortByDisplayName(a, b) {
}
return 0;
}
+
+export function getChannelTerm(channelType) {
+ let channelTerm = 'Channel';
+ if (channelType === Constants.PRIVATE_CHANNEL) {
+ channelTerm = 'Group';
+ }
+
+ return channelTerm;
+}
diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss
index ef19ac601..b57c51242 100644
--- a/web/sass-files/sass/partials/_post.scss
+++ b/web/sass-files/sass/partials/_post.scss
@@ -610,3 +610,81 @@ body.ios {
font-weight: 600;
margin: 0 0 0 -4px;
}
+
+.attachment {
+ .attachment__content {
+ border-width: 1px;
+ border-style: solid;
+ border-radius: 4px;
+ padding: 2px 5px;
+ margin: 0 0 5px 0;
+ }
+ .attachment__thumb-pretext {
+ border: 0 none;
+ background: transparent;
+ }
+ .attachment__container {
+ border-left-width: 4px;
+ border-left-style: solid;
+ padding: 2px 0 2px 10px;
+ &.attachment__container--good {
+ border-left-color: #00C100;
+ }
+ &.attachment__container--warning {
+ border-left-color: #DEDE01;
+ }
+ &.attachment__container--danger {
+ border-left-color: #E40303;
+ }
+ }
+ .attachment__body {
+ float: left;
+ width: 80%;
+ padding-right: 5px;
+ &.attachment__body--no_thumb {
+ width: 100%;
+ }
+ }
+ .attachment__text p:last-of-type {
+ display: inline-block;
+ }
+ .attachment__thumb-pretext {
+ margin-left: 5px;
+ }
+ .attachment__title {
+ margin: 5px 0;
+ padding: 0;
+ line-height: 16px;
+ font-size: 16px;
+ a {
+ font-size: 16px;
+ }
+ }
+ .attachment__author-icon {
+ @include border-radius(50px);
+ margin-right: 5px;
+ width: 14px;
+ height: 14px;
+ }
+ .attachment__image {
+ max-width: 100%;
+ margin-bottom: 1em;
+ }
+ .attachment__thumb-container {
+ width: 20%;
+ float: right;
+ img {
+ height: 75px;
+ max-width: 100%;
+ }
+ }
+ .attachment___fields {
+ width: 100%;
+ .attachment___field-caption {
+ font-weight: 700;
+ }
+ .attachment___field p {
+ margin: 0;
+ }
+ }
+} \ No newline at end of file
diff --git a/web/sass-files/sass/partials/_tutorial.scss b/web/sass-files/sass/partials/_tutorial.scss
index c1bf5fd59..08d491fd9 100644
--- a/web/sass-files/sass/partials/_tutorial.scss
+++ b/web/sass-files/sass/partials/_tutorial.scss
@@ -171,6 +171,10 @@
margin-bottom: 30px;
font-weight: 600;
}
+ .tutorial__circles {
+ position: absolute;
+ bottom: 40px;
+ }
}
.tutorial__circles {
@@ -188,4 +192,4 @@
@include opacity(1);
}
}
-} \ No newline at end of file
+}
diff --git a/web/static/images/bing.mp3 b/web/static/images/bing.mp3
new file mode 100644
index 000000000..2827addd4
--- /dev/null
+++ b/web/static/images/bing.mp3
Binary files differ
diff --git a/web/templates/head.html b/web/templates/head.html
index a73e809a7..2bbf921ee 100644
--- a/web/templates/head.html
+++ b/web/templates/head.html
@@ -52,6 +52,15 @@
headers: { 'X-MM-TokenIndex': mm_session_token_index }
});
}
+
+ $(function () {
+ $(window).bind('storage', function (e) {
+ if (e.originalEvent.key === '__logout__') {
+ console.log('detected logout from a different tab');
+ window.location.href = '/' + window.mm_team.name;
+ }
+ });
+ });
</script>
<script>
diff --git a/web/web.go b/web/web.go
index 51f6664b6..1cae604ae 100644
--- a/web/web.go
+++ b/web/web.go
@@ -983,6 +983,11 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
parsedRequest = model.IncomingWebhookRequestFromJson(strings.NewReader(r.FormValue("payload")))
}
+ if parsedRequest == nil {
+ c.Err = model.NewAppError("incomingWebhook", "Unable to parse incoming data", "")
+ return
+ }
+
text := parsedRequest.Text
if len(text) == 0 {
c.Err = model.NewAppError("incomingWebhook", "No text specified", "")
@@ -990,6 +995,16 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
}
channelName := parsedRequest.ChannelName
+ webhookType := parsedRequest.Type
+
+ //attachments is in here for slack compatibility
+ if parsedRequest.Attachments != nil {
+ if len(parsedRequest.Props) == 0 {
+ parsedRequest.Props = make(model.StringInterface)
+ }
+ parsedRequest.Props["attachments"] = parsedRequest.Attachments
+ webhookType = model.POST_SLACK_ATTACHMENT
+ }
var hook *model.IncomingWebhook
if result := <-hchan; result.Err != nil {
@@ -1039,7 +1054,7 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
return
}
- if _, err := api.CreateWebhookPost(c, channel.Id, text, overrideUsername, overrideIconUrl); err != nil {
+ if _, err := api.CreateWebhookPost(c, channel.Id, text, overrideUsername, overrideIconUrl, parsedRequest.Props, webhookType); err != nil {
c.Err = err
return
}