├── .gitignore ├── Gemfile ├── Gemfile.lock ├── README.md ├── app.rb ├── assets ├── css │ └── style.css.scss └── js │ ├── app.js │ ├── tags.js │ └── utils.js ├── config.ru └── views ├── error.erb ├── index.erb ├── random.erb ├── slack.erb └── slackSuccess.erb /.gitignore: -------------------------------------------------------------------------------- 1 | # public/gifs is a symlink to the gifs directory, so needs setting up per-machine 2 | public/gifs 3 | 4 | # secrets.rb is where app secrets are kept. top secret. shhh. 5 | secrets.rb 6 | 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "sinatra" 4 | gem "sinatra-contrib" 5 | gem "sprockets-helpers" 6 | gem "autoprefixer-rails" 7 | gem "sass" 8 | gem "rest-client" 9 | 10 | # Caching 11 | gem "dalli" 12 | gem "rack-cache" 13 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | autoprefixer-rails (6.3.4) 5 | execjs 6 | backports (3.6.8) 7 | concurrent-ruby (1.1.7) 8 | dalli (2.7.6) 9 | domain_name (0.5.20160310) 10 | unf (>= 0.0.5, < 1.0.0) 11 | execjs (2.6.0) 12 | http-cookie (1.0.2) 13 | domain_name (~> 0.5) 14 | mime-types (2.99.1) 15 | multi_json (1.11.2) 16 | netrc (0.11.0) 17 | rack (1.6.13) 18 | rack-cache (1.6.1) 19 | rack (>= 0.4) 20 | rack-protection (1.5.5) 21 | rack 22 | rack-test (0.6.3) 23 | rack (>= 1.0) 24 | rest-client (1.8.0) 25 | http-cookie (>= 1.0.2, < 2.0) 26 | mime-types (>= 1.16, < 3.0) 27 | netrc (~> 0.7) 28 | sass (3.4.21) 29 | sinatra (1.4.7) 30 | rack (~> 1.5) 31 | rack-protection (~> 1.4) 32 | tilt (>= 1.3, < 3) 33 | sinatra-contrib (1.4.6) 34 | backports (>= 2.0) 35 | multi_json 36 | rack-protection 37 | rack-test 38 | sinatra (~> 1.4.0) 39 | tilt (>= 1.3, < 3) 40 | sprockets (4.0.2) 41 | concurrent-ruby (~> 1.0) 42 | rack (> 1, < 3) 43 | sprockets-helpers (1.2.1) 44 | sprockets (>= 2.2) 45 | tilt (2.0.2) 46 | unf (0.1.4) 47 | unf_ext 48 | unf_ext (0.0.7.2) 49 | 50 | PLATFORMS 51 | ruby 52 | 53 | DEPENDENCIES 54 | autoprefixer-rails 55 | dalli 56 | rack-cache 57 | rest-client 58 | sass 59 | sinatra 60 | sinatra-contrib 61 | sprockets-helpers 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gifme 2 | Gifme is a super-simple Ruby and JS app for grabbing, previewing, and linking to individual files 3 | in a directory of images. Namely, animated gifs. 4 | 5 | Now also a Slack app! 6 | 7 | Add to Slack 8 | 9 | ## Why would you do this 10 | I wanted to be able to search my gif library and do so in a way more fancy/interesting than CMD+F. 11 | I also wanted previews that were bandwidth-friendly, and (eventually) a tagging system with both 12 | global and local tags. 13 | 14 | I then wanted to make use of the app to basically use as a replacement for Slack’s Giphy 15 | integration. Giphy integration is neat, but searching my own gif library from the comfort of a 16 | Slack channel is neater. 17 | 18 | ## How did you do this 19 | Take a look through the code and find out! But the TL;DR is this: 20 | 21 | - The whole thing is a one-view Sinatra/Rack app with JavaScript search 22 | - All routes except for `/` resolve as `/gifs/{request}` 23 | - There is a symlink to a directory of images (gifs) in `public` 24 | - This is where you'd put (or symlink) your images/gifs if you wanted to run it yourself 25 | - I'm using [Passenger](https://www.phusionpassenger.com) to run the Rack app on my Apache server 26 | -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | 4 | Bundler.require :default, (ENV["RACK_ENV"] || "development").to_sym 5 | 6 | require_relative 'secrets' 7 | 8 | class App < Sinatra::Base 9 | set :sprockets, Sprockets::Environment.new(root) 10 | set :assets_prefix, '/assets' 11 | set :digest_assets, false 12 | set :logging, true 13 | 14 | configure do 15 | # Setup Sprockets 16 | sprockets.append_path File.join(root, 'assets', 'css') 17 | sprockets.append_path File.join(root, 'assets', 'js') 18 | sprockets.append_path File.join(root, 'assets', 'images') 19 | 20 | # Configure Sprockets::Helpers (if necessary) 21 | Sprockets::Helpers.configure do |config| 22 | config.environment = sprockets 23 | config.prefix = assets_prefix 24 | config.digest = digest_assets 25 | config.public_path = public_folder 26 | 27 | # Force to debug mode in development mode 28 | # Debug mode automatically sets 29 | # expand = true, digest = false, manifest = false 30 | config.debug = true if development? 31 | end 32 | end 33 | 34 | require "autoprefixer-rails" 35 | AutoprefixerRails.install(sprockets) 36 | 37 | helpers do 38 | include Sprockets::Helpers 39 | 40 | # Alternative method for telling Sprockets::Helpers which 41 | # Sprockets environment to use. 42 | # def assets_environment 43 | # settings.sprockets 44 | # end 45 | end 46 | 47 | get '/' do 48 | cache_control :public, max_age: 86400 49 | dir = settings.public_folder + "/gifs" 50 | gifs = Dir.foreach(dir).select { |x| File.file?("#{dir}/#{x}") } 51 | gifs = gifs.shuffle 52 | erb :index, :locals => {:images => gifs} 53 | end 54 | 55 | get '/random' do 56 | dir = settings.public_folder + "/gifs" 57 | gifs = Dir.foreach(dir).select { |x| File.file?("#{dir}/#{x}") } 58 | gif = gifs.sample 59 | erb :random, :locals => {:gif => gif} 60 | end 61 | 62 | get '/slack' do 63 | cache_control :public, max_age: 86400 64 | erb :slack 65 | end 66 | 67 | get '/slack/callback' do 68 | api_result = RestClient.get "https://slack.com/api/oauth.access?client_id=#{$client_id}&client_secret=#{$client_secret}&code=#{params[:code]}" 69 | jhash = JSON.parse(api_result) 70 | 71 | puts jhash 72 | 73 | redirect '/slack/success' 74 | end 75 | 76 | get '/slack/success' do 77 | erb :slackSuccess 78 | end 79 | 80 | # Any request that isn't '/' we can probably assume is trying to direct-link an image. 81 | get '/:file' do 82 | cache_control :public, max_age: 86400 83 | ext = params[:file].split('.')[1] 84 | file = File.join(settings.public_folder, "gifs", params[:file]) 85 | puts ext 86 | headers["Content-Type"] = "image/" + ext 87 | headers["Cache-Control"] = "public, max-age=2678400" 88 | headers["Content-Length"] = File.size?(file) 89 | puts headers 90 | send_file file 91 | end 92 | 93 | post '/api/v0/sample' do 94 | content_type :json 95 | query = params[:text] 96 | token = params[:token] 97 | 98 | unless $tokens.include?(token) 99 | json( 100 | "response_type": "ephemeral", 101 | "text": "Hmm. Looks like this was an unauthorized request. I'm just going to ignore you." 102 | ) 103 | abort("Unauthorized token") 104 | end 105 | 106 | dir = settings.public_folder + "/gifs" 107 | gifs = Dir.foreach(dir).select { |x| File.file?("#{dir}/#{x}") } 108 | 109 | if query != nil 110 | gif = gifs.select{ |i| i[/#{query}/] } 111 | end 112 | 113 | gif = gif.sample 114 | 115 | if gif != nil 116 | response = "" 117 | json( 118 | "response_type": "in_channel", 119 | "text": response, 120 | "unfurl_links": true, 121 | "unfurl_media": true 122 | ) 123 | else 124 | json( 125 | "response_type": "ephemeral", 126 | "text": "Ugh. There weren't any gifs matching '" + query + "'. My bad. \nYou could always go to and look for one yourself." 127 | ) 128 | end 129 | 130 | end 131 | 132 | get '/api/v0/all' do 133 | content_type :json 134 | 135 | dir = settings.public_folder + "/gifs" 136 | gifs = Dir.foreach(dir).select { |x| File.file?("#{dir}/#{x}") } 137 | 138 | if gifs != nil 139 | json( 140 | "images": gifs 141 | ) 142 | else 143 | json( 144 | "error": "Something went wrong" 145 | ) 146 | end 147 | end 148 | 149 | end 150 | -------------------------------------------------------------------------------- /assets/css/style.css.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Reset 3 | */ 4 | 5 | * { 6 | margin: 0; 7 | padding: 0; 8 | box-sizing: inherit; 9 | position: relative; 10 | } 11 | 12 | 13 | /** 14 | * Base styles 15 | */ 16 | 17 | body { 18 | box-sizing: border-box; 19 | 20 | font: 87.5%/1.5 "BlinkMacSystemFont", -apple-system, sans-serif; 21 | 22 | background-color: #25282b; 23 | } 24 | 25 | 26 | /** 27 | * Search input 28 | */ 29 | 30 | .search { 31 | position: fixed; 32 | top: 0; 33 | right: 0; 34 | left: 0; 35 | width: 100%; 36 | padding: 2vmax; 37 | z-index: 1; 38 | 39 | -webkit-font-smoothing: antialiased; 40 | font-family: inherit; 41 | font-size: 2vmax; 42 | font-weight: 400; 43 | line-height: 1.5; 44 | 45 | color: #fff; 46 | border: none; 47 | background-color: rgba(0,0,0,.9); 48 | backdrop-filter: blur(15px); 49 | outline: none; 50 | 51 | appearance: none; 52 | } 53 | 54 | 55 | /** 56 | * Content 57 | */ 58 | 59 | .thumbnails { 60 | padding-top: 7vmax; 61 | list-style: none; 62 | display: flex; 63 | flex-wrap: wrap; 64 | 65 | &__item { 66 | display: block; 67 | width: 20vw; 68 | height: 15vw; 69 | overflow: hidden; 70 | 71 | background-size: cover; 72 | background-position: center; 73 | 74 | &.is-hidden { 75 | display: none; 76 | } 77 | } 78 | } 79 | 80 | .item__img { 81 | display: block; 82 | position: absolute; 83 | top: 0; 84 | left: 0; 85 | width: 100%; 86 | height: 100%; 87 | object-fit: cover; 88 | 89 | line-height: 1; 90 | 91 | &.is-hidden { 92 | display: none; 93 | } 94 | } 95 | 96 | .item__info { 97 | position: absolute; 98 | bottom: 0; 99 | left: 0; 100 | right: 0; 101 | padding: .5em; 102 | 103 | color: #fff; 104 | background-color: rgba(0,0,0,.8); 105 | backdrop-filter: blur(15px); 106 | transform: translate3d(0, 100%, 0); 107 | transition: transform .25s ease; 108 | 109 | .thumbnails__item:hover & { 110 | transform: translate3d(0,0,0); 111 | } 112 | 113 | a { 114 | color: inherit; 115 | } 116 | } 117 | 118 | .page--slack { 119 | min-height: 100vh; 120 | display: flex; 121 | align-items: center; 122 | justify-content: center; 123 | 124 | .content { 125 | max-width: 600px; 126 | padding: 5vmin; 127 | margin: 0 auto; 128 | 129 | color: #F9F8F3; 130 | text-align: center; 131 | 132 | p { 133 | margin-bottom: 1.5rem; 134 | } 135 | 136 | code { 137 | display: inline-block; 138 | padding: 1px 3px; 139 | background-color: #F9F8F3; 140 | border: 1px solid rgba(0,0,0,.1); 141 | border-radius: 3px; 142 | color: #3D464D; 143 | } 144 | } 145 | 146 | } 147 | 148 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | //= require utils 2 | //= require tags 3 | 4 | // GifMe 5 | // ===== 6 | // 7 | // Basically a private giphy on gif.daneden.me with search 8 | // and local/per-browser tagging. 9 | // 10 | // Copyright (c) 2015 Daniel Eden 11 | // @_dte 12 | 13 | "use strict"; 14 | 15 | (function (files) { 16 | // Set up our global vars 17 | // 'names' and 'thumbs' are almost identical, where 'thumbs' is the DOM representation of 'names' 18 | var thumbs = Array.prototype.slice.call(document.querySelectorAll('.js-thumb')); 19 | var names = []; 20 | 21 | var thumbParents = Array.prototype.slice.call(document.querySelectorAll('.thumbnails__item')); 22 | var search = document.querySelector('.js-search'); 23 | var tagIndex = []; 24 | 25 | // Add event listeners to each thumbnail and load our search index 26 | thumbs.forEach(function(thumb) { 27 | names.push(thumb.getAttribute('data-name')); 28 | }); 29 | 30 | thumbParents.forEach(function(el) { 31 | el.addEventListener('mouseenter', swapSource); 32 | el.addEventListener('mouseleave', swapSource); 33 | el.addEventListener('click', () => fbq('track', 'ViewContent')) 34 | }); 35 | 36 | // Attach tags to the images 37 | files.forEach(function(item) { 38 | var i = names.indexOf(item.filename); 39 | thumbs[i].setAttribute('data-tags', item.tags); 40 | 41 | // Add tags to the tag index and remove duplicates 42 | tagIndex = tagIndex.concat(item.tags).unique(); 43 | }); 44 | 45 | // Add event listener to the search input 46 | search.addEventListener('keyup', handleSearch); 47 | search.addEventListener('change', handleSearch); 48 | 49 | // handleSearch function 50 | // --------------------- 51 | // 52 | // Handles the search bar update 53 | // 54 | function handleSearch(event) { 55 | if(event.target.value != '') { 56 | var query = event.target.value; 57 | var nameMatches = queryIndex(event, names); 58 | var tagMatches = queryIndex(event, tagIndex); 59 | 60 | tagMatches = filterFilesByTags(tagMatches); 61 | 62 | // Combine the arrays to show files that match by both name and tags 63 | var matches = nameMatches.concat(tagMatches).unique(); 64 | 65 | // Filter the results based on our tag/name matches 66 | filterResultsByName(matches); 67 | window.history.replaceState(matches, query + " - Gifme Search", "/?s=" + query); 68 | } else { 69 | clearFilter(); 70 | window.history.replaceState(null, "Gifme", "/"); 71 | } 72 | 73 | fbq('track', 'Search') 74 | } 75 | 76 | // swapSource function 77 | // ------------------- 78 | // 79 | // Shows animated/actual preview of image on hover. 80 | // 81 | // Arguments: 82 | // 83 | // e (event): the event triggering the function 84 | // 85 | function swapSource(e) { 86 | var imgParent = e.target; 87 | var img = imgParent.childNodes[1]; 88 | var src = img.getAttribute('data-name'); 89 | 90 | var imgClone; 91 | 92 | if(img.nextSibling.nodeName == "IMG") { 93 | imgClone = img.nextSibling; 94 | } else { 95 | imgClone = img.cloneNode(); 96 | imgClone.src = './' + src; 97 | imgClone.classList.add('is-hidden'); 98 | imgParent.insertBefore(imgClone, img.nextSibling); 99 | } 100 | 101 | if (e.type == 'mouseenter') { 102 | // We don't want to change the src of the image since doing so 103 | // would result in separate HTTP requests on every hover. 104 | // 105 | // Instead, add a background image to the container. 106 | // 107 | // Downside: gifs will keep looping in the background, and many 108 | // background-images might slow the client down. 109 | // 110 | 111 | // Hide the static/low-quality thumb 112 | imgClone.classList.remove('is-hidden'); 113 | 114 | } else if (e.type == 'mouseleave') { 115 | // When moving away from the thumb, make it visible again 116 | imgClone.classList.add('is-hidden'); 117 | } 118 | } 119 | 120 | // queryIndex function 121 | // -------------------- 122 | // 123 | // Search/filter our names array with a case-insensitive query 124 | // from the search input. Returns an array of matching strings. 125 | // 126 | // Arguments: 127 | // 128 | // e (event): the event triggering the function 129 | // index (array): an array of strings to check e.target.value against 130 | // 131 | function queryIndex(e, index) { 132 | if (index == '' || index == null) { 133 | index = names; 134 | } 135 | var input = e.target; 136 | var val = input.value.split(" "); 137 | var results = []; 138 | 139 | val.forEach(function(q) { 140 | q = new RegExp(q, "i"); 141 | 142 | var result = index.filter(function(item){ 143 | return q.test(item); 144 | }); 145 | 146 | results.push(result); 147 | }); 148 | 149 | // Flatten the results arrays 150 | var flattened = []; 151 | results.forEach(function(current) { 152 | current.forEach(function(r) { 153 | flattened.push(r); 154 | }); 155 | }); 156 | 157 | // De-dupe the flattened array 158 | results = flattened.unique(); 159 | 160 | // Return the array 161 | return results; 162 | } 163 | 164 | // filterResultsByName function 165 | // ---------------------- 166 | // 167 | // Filter and hide image thumbnails based on results from the 168 | // queryIndex function 169 | // 170 | // Arguments: 171 | // 172 | // results (array): a subset of our names variable 173 | // 174 | function filterResultsByName(results) { 175 | // Initially hide all thumbnail parents 176 | thumbParents.forEach(function(el) { 177 | el.classList.add('is-hidden'); 178 | }); 179 | 180 | // Filter matching results and unhide the parents 181 | results.forEach(function(result) { 182 | var pos = names.indexOf(result); 183 | 184 | if (pos >= 0) { 185 | thumbParents[pos].classList.remove('is-hidden'); 186 | } 187 | }); 188 | } 189 | 190 | // filterFilesByTags function 191 | // ---------------------- 192 | // 193 | // Filter and hide image thumbnails based on results from the 194 | // queryIndex function. Returns an array of files with matching 195 | // tags. 196 | // 197 | // Arguments: 198 | // 199 | // results (array): a subset of our tags variable 200 | // 201 | function filterFilesByTags(results) { 202 | var filteredResults = []; 203 | 204 | results.forEach(function(result, i) { 205 | files.forEach(function(thumb, i) { 206 | var file = thumb.filename; 207 | var tags = thumb.tags; 208 | 209 | tags.forEach(function(tag) { 210 | if(result == tag) filteredResults.push(file); 211 | }); 212 | }); 213 | }); 214 | 215 | return filteredResults.unique(); 216 | } 217 | 218 | // clearFilter function 219 | // -------------------- 220 | // 221 | // Clears all filters on the results 222 | // 223 | function clearFilter() { 224 | thumbParents.forEach(function(el) { 225 | el.classList.remove('is-hidden'); 226 | }); 227 | } 228 | 229 | // Check if there's a query sent via URL query string 230 | function prepSearch() { 231 | var query = window.location.search; 232 | 233 | if (query.indexOf('?s=') == 0) { 234 | query = query.replace('?s=', ''); 235 | search.value = query; 236 | if ("createEvent" in document) { 237 | var evt = document.createEvent("HTMLEvents"); 238 | evt.initEvent("change", false, true); 239 | search.dispatchEvent(evt); 240 | } else { 241 | search.fireEvent("onchange"); 242 | } 243 | } else { 244 | return 245 | } 246 | } 247 | 248 | document.addEventListener("DOMContentLoaded", prepSearch(), false); 249 | }(gifmeFiles)); 250 | -------------------------------------------------------------------------------- /assets/js/tags.js: -------------------------------------------------------------------------------- 1 | var gifmeFiles = [ 2 | { 3 | filename: "git-merge.gif", 4 | tags: ["fail", "git"] 5 | }, 6 | 7 | { 8 | filename: "git-rebase.gif", 9 | tags: ["git"] 10 | }, 11 | 12 | { 13 | filename: "act-natural.gif", 14 | tags: ["animal", "cat"] 15 | }, 16 | 17 | { 18 | filename: "snugg-attack.gif", 19 | tags: ["animal", "cat", "snuggle"] 20 | }, 21 | 22 | { 23 | filename: "wtf-do-you-think-you-are-doing.gif", 24 | tags: ["animal", "cat"] 25 | }, 26 | 27 | { 28 | filename: "loading-wiggle.gif", 29 | tags: ["animal", "cat"] 30 | }, 31 | 32 | { 33 | filename: "dangerbutt.gif", 34 | tags: ["animal", "cat"] 35 | }, 36 | 37 | { 38 | filename: "sleepy3.gif", 39 | tags: ["animal", "cat"] 40 | }, 41 | 42 | { 43 | filename: "picard-code-review.jpg", 44 | tags: ["git", "code"] 45 | }, 46 | 47 | { 48 | filename: "stay-in-bed.gif", 49 | tags: ["animal", "cat", "snuggle"] 50 | }, 51 | 52 | { 53 | filename: "nap-time.gif", 54 | tags: ["animal", "dog", "sleep", "liz"] 55 | }, 56 | 57 | { 58 | filename: "yaaaaay.gif", 59 | tags: ["yay", "success"] 60 | }, 61 | { 62 | filename: "life.gif", 63 | tags: ["fml"], 64 | }, 65 | { 66 | filename: "icant.gif", 67 | tags: ["fml"], 68 | }, 69 | { 70 | filename: "i-cant.png", 71 | tags: ["fml"], 72 | }, 73 | { 74 | filename: "howiwritemorejavascripts.gif", 75 | tags: ["fml"], 76 | }, 77 | { 78 | filename: "howiwritejavascripts.gif", 79 | tags: ["fml"], 80 | }, 81 | { 82 | filename: "howidobasicallyeverything.gif", 83 | tags: ["fml"], 84 | }, 85 | { 86 | filename: "git-rebase.gif", 87 | tags: ["fml"], 88 | }, 89 | { 90 | filename: "git-push-f-lebron.gif", 91 | tags: ["fml"], 92 | }, 93 | { 94 | filename: "git-pop.gif", 95 | tags: ["fml"], 96 | }, 97 | { 98 | filename: "git-merge4.gif", 99 | tags: ["fml"], 100 | }, 101 | { 102 | filename: "git-merge-conflict.gif", 103 | tags: ["fml"], 104 | }, 105 | { 106 | filename: "git-cherry-pick.gif", 107 | tags: ["fml"], 108 | }, 109 | { 110 | filename: "hello-world.gif", 111 | tags: ["fml"], 112 | }, 113 | { 114 | filename: "hello-darkness-my-old-friend.gif", 115 | tags: ["fml"], 116 | }, 117 | { 118 | filename: "waaaaaaaah.gif", 119 | tags: ["anger", "excitement"], 120 | }, 121 | { 122 | filename: "pay-attention-to-me.gif", 123 | tags: ["cat"], 124 | }, 125 | ]; 126 | -------------------------------------------------------------------------------- /assets/js/utils.js: -------------------------------------------------------------------------------- 1 | // Utilities 2 | // ========= 3 | 4 | // Concat arrays and remove duplicate entries 5 | Array.prototype.unique = function() { 6 | var a = this.concat(); 7 | for(var i=0; i 2 | 3 | 4 | Error 5 | 6 | 7 |

<%= message %>

8 | 9 | 10 | -------------------------------------------------------------------------------- /views/index.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Gifme 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 | 27 | 28 | 29 | 38 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /views/random.erb: -------------------------------------------------------------------------------- 1 | 2 | 20 | 21 | -------------------------------------------------------------------------------- /views/slack.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Gifme Slack App 5 | 6 | 7 | 8 | 9 | 10 |
11 |

Gifme for Slack

12 |

Hey, so you want gifs and you don’t want to use Giphy. That’s cool.

13 |

Click this button and you’ll be able to type /gifme lol and get a gif from gif.daneden.me with the word “lol” in the title.

14 |

Pretty cool, huh?

15 |

16 | 17 | Add to Slack 18 | 19 |

20 |
21 | 22 | 31 | 34 | 35 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /views/slackSuccess.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Gifme Slack App 5 | 6 | 7 | 8 | 9 | 10 |
11 |

You’re all done.

12 |

Go back to your Slack team and try typing /gifme <%= ['lol', 'robit', 'cat', 'hello', 'nope', 'git'].sample %>.

13 |

Enjoy your new gif superpowers.

14 |
15 | 16 | 25 | 28 | 29 | 32 | 33 | 34 | --------------------------------------------------------------------------------