├── TODO ├── pom.xml └── src ├── main ├── java │ └── com │ │ └── wideplay │ │ └── crosstalk │ │ ├── CrosstalkModule.java │ │ ├── data │ │ ├── Attachment.java │ │ ├── ConnectedClients.java │ │ ├── JsonHide.java │ │ ├── LoginToken.java │ │ ├── Message.java │ │ ├── Occupancy.java │ │ ├── Room.java │ │ ├── RoomTextIndex.java │ │ ├── User.java │ │ ├── buzz │ │ │ ├── BuzzSearch.java │ │ │ └── BuzzUser.java │ │ ├── indexing │ │ │ ├── PorterStemmer.java │ │ │ └── StopWords.java │ │ ├── store │ │ │ ├── MessageStore.java │ │ │ ├── RoomStore.java │ │ │ ├── StoreModule.java │ │ │ └── UserStore.java │ │ └── twitter │ │ │ ├── TwitterSearch.java │ │ │ └── TwitterUser.java │ │ └── web │ │ ├── AsyncIndexService.java │ │ ├── AsyncPostService.java │ │ ├── AttachmentService.java │ │ ├── Broadcaster.java │ │ ├── ClientRequest.java │ │ ├── CurrentUser.java │ │ ├── HomePage.java │ │ ├── RoomAdminPage.java │ │ ├── RoomPage.java │ │ ├── RootPage.java │ │ ├── SitebricksConfig.java │ │ ├── UploadService.java │ │ ├── WebModule.java │ │ ├── auth │ │ ├── AdminMethodInterceptor.java │ │ ├── AdminOnly.java │ │ ├── AuthModule.java │ │ ├── Login.java │ │ ├── Logout.java │ │ ├── Secure.java │ │ ├── SecureMethodInterceptor.java │ │ ├── buzz │ │ │ ├── BuzzApi.java │ │ │ ├── BuzzAuthFilter.java │ │ │ ├── BuzzAuthModule.java │ │ │ ├── BuzzOAuthCallback.java │ │ │ └── GoogleComSecureAuthFilter.java │ │ └── twitter │ │ │ ├── Twitter.java │ │ │ ├── TwitterAuthFilter.java │ │ │ ├── TwitterAuthModule.java │ │ │ ├── TwitterMode.java │ │ │ └── TwitterOAuthCallback.java │ │ └── tasks │ │ ├── BackgroundBuzzService.java │ │ ├── BackgroundTasksModule.java │ │ ├── BackgroundTextClusterer.java │ │ ├── BackgroundTweetService.java │ │ └── TaskQueue.java ├── resources │ └── com │ │ └── wideplay │ │ └── crosstalk │ │ └── data │ │ └── indexing │ │ └── stopwords.txt └── webapp │ ├── AboutPage.html │ ├── HomePage.xml │ ├── RoomAdminPage.html │ ├── RoomPage.html │ ├── WEB-INF │ ├── appengine-web.xml │ ├── datastore-indexes.xml │ └── web.xml │ ├── crosstalk.css │ ├── crosstalk_mobile.css │ ├── fileuploader.css │ ├── fonts │ ├── fonts.css │ ├── gill_sans_mt-webfont.eot │ ├── gill_sans_mt-webfont.svg │ ├── gill_sans_mt-webfont.ttf │ └── gill_sans_mt-webfont.woff │ ├── images │ ├── arrow_left.png │ ├── arrow_right.png │ ├── arrow_up.png │ ├── avatar.png │ ├── body.gif │ ├── cloud.png │ ├── cloud_pointer1.png │ ├── cloud_pointer2.png │ ├── logo.png │ ├── logo_mobile.gif │ ├── new_room_arrow.gif │ ├── pen.png │ ├── room_status_green.png │ ├── room_status_green_lrg.png │ ├── room_status_grey.png │ ├── room_status_grey_lrg.png │ ├── room_status_orange.png │ ├── room_status_orange_lrg.png │ ├── swish.png │ ├── twitter1.jpg │ ├── twitter_login.gif │ ├── webstock.gif │ └── webstock_mobile.gif │ ├── js │ ├── crosstalk.js │ ├── fileuploader.js │ ├── jquery-1.4.1.min.js │ ├── jquery-1.5.min.js │ ├── jquery.embedly.min.js │ ├── json2.js │ └── main.js │ ├── main.css │ └── main_mobile.css └── test └── java └── com └── wideplay └── crosstalk └── data └── twitter ├── JsonTweetParsingTest.java ├── example_buzz_@me.json ├── example_buzz_search.json ├── example_tweet_search.json └── example_twitter_creds.json /TODO: -------------------------------------------------------------------------------- 1 | Next features 2 | ------------- 3 | 4 | x Persistence 5 | x Dynamic rooms (i.e. more than 1) 6 | x Wire up anonymous to lurkers counter 7 | x Multiple rooms per user connected 8 | x JS date formatter 9 | - Tabs (links, images, etc--should be cake with jquery and proper class tagging) 10 | x Bubble activity index 11 | - "Infinite" conversation load (i.e. message-list cursors) 12 | x Incremental updates to bubble index (should be requested by client) 13 | x Leave room (onunload handler) 14 | 15 | x File uploads 16 | x Embed for arbitrary images 17 | 18 | LATER 19 | 20 | x Home screen 21 | x Create new room UI(?) 22 | x Editing hashtags 23 | x Pull hashtag content from twitter 24 | - Memcache for users/rooms (especially occupancy) 25 | - Fast scroll (via minimap) 26 | - Leave room (idleness checker) -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 4 | 4.0.0 5 | com.wideplay.crosstalk 6 | crosstalk 7 | war 8 | 0.1 9 | crosstalk 10 | http://twitter.com/dhanji 11 | 12 | 13 | 14 | objectify-appengine 15 | http://objectify-appengine.googlecode.com/svn/maven 16 | 17 | 18 | 19 | 20 | 21 | javax.persistence 22 | persistence-api 23 | 1.0 24 | 25 | 26 | com.google.appengine 27 | appengine-api-1.0-sdk 28 | 1.4.0 29 | 30 | 31 | com.googlecode.objectify 32 | objectify 33 | 2.2.2 34 | 35 | 36 | org.sonatype.sisu.inject 37 | guice-persist 38 | 2.9.2 39 | 40 | 41 | com.google.code.gson 42 | gson 43 | 1.6 44 | 45 | 46 | com.google.sitebricks 47 | sitebricks 48 | 0.8.3-SNAPSHOT 49 | 50 | 51 | org.slf4j 52 | slf4j-api 53 | 1.6.1 54 | 55 | 56 | org.slf4j 57 | slf4j-jdk14 58 | 1.6.1 59 | 60 | 61 | junit 62 | junit 63 | 4.8.2 64 | 65 | 66 | oauth.signpost 67 | signpost-core 68 | 1.2.1 69 | 70 | 71 | commons-fileupload 72 | commons-fileupload 73 | 1.2.2 74 | 75 | 76 | net.sf.jsr107cache 77 | jsr107cache 78 | 1.0 79 | 80 | 81 | com.google.api.client 82 | google-api-data-buzz-v1 83 | 1.0.9-alpha 84 | 85 | 86 | com.google.api.client 87 | google-api-client 88 | 1.2.2-alpha 89 | 90 | 91 | 92 | 93 | src/main/java 94 | src/test/java 95 | 96 | 97 | src/main/resources 98 | 99 | 100 | src/main/webapp/WEB-INF/classes 101 | 102 | 103 | maven-compiler-plugin 104 | 105 | 1.6 106 | 1.6 107 | 108 | 109 | 110 | net.kindleit 111 | maven-gae-plugin 112 | 0.8.1 113 | 114 | /Users/dhanji/src/lib/appengine 115 | 116 | 117 | 118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/CrosstalkModule.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk; 2 | 3 | import com.google.appengine.api.channel.ChannelService; 4 | import com.google.appengine.api.channel.ChannelServiceFactory; 5 | import com.google.gson.ExclusionStrategy; 6 | import com.google.gson.FieldAttributes; 7 | import com.google.gson.Gson; 8 | import com.google.gson.GsonBuilder; 9 | import com.google.gson.LongSerializationPolicy; 10 | import com.google.inject.Provides; 11 | import com.google.inject.Singleton; 12 | import com.google.inject.servlet.RequestScoped; 13 | import com.google.sitebricks.SitebricksModule; 14 | import com.google.sitebricks.headless.Request; 15 | import com.wideplay.crosstalk.data.JsonHide; 16 | import com.wideplay.crosstalk.data.store.StoreModule; 17 | import com.wideplay.crosstalk.web.ClientRequest; 18 | import com.wideplay.crosstalk.web.SitebricksConfig; 19 | import com.wideplay.crosstalk.web.WebModule; 20 | import com.wideplay.crosstalk.web.tasks.BackgroundTasksModule; 21 | 22 | /** 23 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 24 | */ 25 | public class CrosstalkModule extends SitebricksModule { 26 | public static final String POST_DATE_FORMAT = "HH:mm"; 27 | public static final String SEGMENT_DATE_FORMAT = "HH:mm"; 28 | 29 | @Override 30 | protected void configureSitebricks() { 31 | scan(SitebricksConfig.class.getPackage()); 32 | 33 | install(new BackgroundTasksModule()); 34 | install(new WebModule()); 35 | install(new StoreModule()); 36 | } 37 | 38 | private static final ExclusionStrategy EXCLUDE = new ExclusionStrategy() { 39 | @Override 40 | public boolean shouldSkipField(FieldAttributes fieldAttributes) { 41 | return fieldAttributes.getAnnotation(JsonHide.class) != null; 42 | } 43 | 44 | @Override 45 | public boolean shouldSkipClass(Class aClass) { 46 | return false; 47 | } 48 | }; 49 | 50 | @Provides 51 | @Singleton 52 | Gson provideGson() { 53 | return new GsonBuilder().setDateFormat(POST_DATE_FORMAT) 54 | .setExclusionStrategies(EXCLUDE) 55 | .setLongSerializationPolicy(LongSerializationPolicy.STRING) 56 | .create(); 57 | } 58 | 59 | @Provides 60 | ChannelService provideChannelService() { 61 | return ChannelServiceFactory.getChannelService(); 62 | } 63 | 64 | @Provides 65 | @RequestScoped 66 | ClientRequest provideClientRequest(Request request, Gson gson) { 67 | return gson.fromJson(request.param("data"), ClientRequest.class); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/Attachment.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data; 2 | 3 | import com.google.appengine.api.datastore.Blob; 4 | import com.googlecode.objectify.Key; 5 | 6 | import javax.persistence.Id; 7 | 8 | /** 9 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 10 | */ 11 | public class Attachment { 12 | @Id 13 | private Long id; 14 | private String name; 15 | private String mimeType; 16 | 17 | @JsonHide 18 | private Key author; 19 | @JsonHide 20 | private Blob content; 21 | 22 | public Long getId() { 23 | return id; 24 | } 25 | 26 | public void setId(Long id) { 27 | this.id = id; 28 | } 29 | 30 | public String getName() { 31 | return name; 32 | } 33 | 34 | public void setName(String name) { 35 | this.name = name; 36 | } 37 | 38 | public String getMimeType() { 39 | return mimeType; 40 | } 41 | 42 | public void setMimeType(String mimeType) { 43 | this.mimeType = mimeType; 44 | } 45 | 46 | public Key getAuthor() { 47 | return author; 48 | } 49 | 50 | public void setAuthor(User author) { 51 | this.author = new Key(User.class, author.getUsername()); 52 | } 53 | 54 | public Blob getContent() { 55 | return content; 56 | } 57 | 58 | public void setContent(Blob content) { 59 | this.content = content; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/ConnectedClients.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data; 2 | 3 | import com.google.common.collect.ImmutableList; 4 | import com.google.common.collect.Lists; 5 | import com.google.common.collect.Maps; 6 | import com.google.common.collect.Sets; 7 | import com.google.inject.Inject; 8 | import com.googlecode.objectify.Key; 9 | import com.googlecode.objectify.Objectify; 10 | import com.googlecode.objectify.annotation.Cached; 11 | import com.googlecode.objectify.annotation.Serialized; 12 | 13 | import javax.persistence.Entity; 14 | import javax.persistence.Id; 15 | import java.io.Serializable; 16 | import java.util.Collection; 17 | import java.util.Map; 18 | import java.util.Set; 19 | 20 | /** 21 | * Encapsulates all connected clients. bit of a hack. 22 | * 23 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 24 | */ 25 | public class ConnectedClients { 26 | private final Objectify objectify; 27 | 28 | @Inject 29 | public ConnectedClients(Objectify objectify) { 30 | this.objectify = objectify; 31 | 32 | // Initialize myself. 33 | usersInRooms.addAll(objectify.query(UserRoom.class).list()); 34 | ensure(); 35 | } 36 | 37 | private Set usersInRooms = Sets.newHashSet(); 38 | 39 | // MEMO FIELD 40 | private Map usersInRoomsMemo; 41 | 42 | public Collection channelOf(String userName, Room room) { 43 | UserRoom userRoom = usersInRoomsMemo.get(userName); 44 | if (null == userRoom) { 45 | return null; 46 | } 47 | 48 | for (RoomTokens roomToken : userRoom.roomTokens) { 49 | if (roomToken.roomKey.getId() == room.getId()) { 50 | return roomToken.tokens; 51 | } 52 | } 53 | 54 | // doesn't exist. 55 | return null; 56 | } 57 | 58 | public boolean remove(Key user, Room room, String id) { 59 | UserRoom userRoom = usersInRoomsMemo.get(user.getName()); 60 | if (null == userRoom) { 61 | return true; 62 | } 63 | 64 | boolean leftRoom = false; 65 | for (RoomTokens roomToken : userRoom.roomTokens) { 66 | if (roomToken.roomKey.getId() == room.getId()) { 67 | roomToken.tokens.remove(id); 68 | leftRoom = roomToken.tokens.isEmpty(); 69 | break; 70 | } 71 | } 72 | objectify.put(userRoom); 73 | 74 | return leftRoom; 75 | } 76 | 77 | public void removeAll(Key user, Room room) { 78 | UserRoom userRoom = usersInRoomsMemo.get(user.getName()); 79 | if (null == userRoom) { 80 | return; 81 | } 82 | 83 | for (RoomTokens roomToken : userRoom.roomTokens) { 84 | if (roomToken.roomKey.getId() == room.getId()) { 85 | roomToken.tokens.clear(); 86 | break; 87 | } 88 | } 89 | objectify.put(userRoom); 90 | } 91 | 92 | @Cached @Entity 93 | public static class UserRoom { 94 | @Id 95 | private String username; 96 | 97 | @Serialized 98 | private Set roomTokens = Sets.newHashSet(); 99 | } 100 | 101 | public static class RoomTokens implements Serializable { 102 | private static final long serialVerionUID = -1L; 103 | private Key roomKey; 104 | private Set tokens = Sets.newHashSet(); 105 | 106 | @Override 107 | public boolean equals(Object o) { 108 | if (this == o) return true; 109 | if (!(o instanceof RoomTokens)) return false; 110 | 111 | RoomTokens that = (RoomTokens) o; 112 | 113 | if (roomKey != null ? !roomKey.equals(that.roomKey) : that.roomKey != null) return false; 114 | 115 | return true; 116 | } 117 | 118 | @Override 119 | public int hashCode() { 120 | return roomKey != null ? roomKey.hashCode() : 0; 121 | } 122 | } 123 | 124 | public void add(String token, User client, Room room) { 125 | UserRoom userRoom = usersInRoomsMemo.get(client.getUsername()); 126 | if (null == userRoom) { 127 | userRoom = new UserRoom(); 128 | userRoom.username = client.getUsername(); 129 | usersInRoomsMemo.put(userRoom.username, userRoom); 130 | usersInRooms.add(userRoom); 131 | } 132 | 133 | RoomTokens found = null; 134 | for (RoomTokens roomToken : userRoom.roomTokens) { 135 | if (roomToken.roomKey.getId() == room.getId()) { 136 | found = roomToken; 137 | break; 138 | } 139 | } 140 | 141 | // Should we create a new one? 142 | if (null == found) { 143 | found = new RoomTokens(); 144 | found.roomKey = new Key(Room.class, room.getId()); 145 | userRoom.roomTokens.add(found); 146 | } 147 | found.tokens.add(token); 148 | 149 | // Remember to save me! 150 | objectify.put(userRoom); 151 | } 152 | 153 | public Collection> getRooms(User user) { 154 | UserRoom userRoom = usersInRoomsMemo.get(user.getUsername()); 155 | if (null == userRoom) { 156 | return ImmutableList.of(); 157 | } 158 | 159 | Collection> rooms = Lists.newArrayList(); 160 | for (RoomTokens roomToken : userRoom.roomTokens) { 161 | rooms.add(roomToken.roomKey); 162 | } 163 | 164 | return rooms; 165 | } 166 | 167 | private void ensure() { 168 | if (null == usersInRoomsMemo) { 169 | usersInRoomsMemo = Maps.newHashMap(); 170 | for (UserRoom usersInRoom : usersInRooms) { 171 | usersInRoomsMemo.put(usersInRoom.username, usersInRoom); 172 | } 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/JsonHide.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | /** 9 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 10 | */ 11 | @Retention(RetentionPolicy.RUNTIME) 12 | @Target(ElementType.FIELD) 13 | public @interface JsonHide { 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/LoginToken.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data; 2 | 3 | import com.googlecode.objectify.annotation.Unindexed; 4 | 5 | import javax.persistence.Entity; 6 | import javax.persistence.Id; 7 | 8 | /** 9 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 10 | */ 11 | @Entity @Unindexed 12 | public class LoginToken { 13 | @Id 14 | private String requestToken; 15 | private String tokenSecret; 16 | private String lastUrl; 17 | 18 | public LoginToken() { 19 | } 20 | 21 | public LoginToken(String requestToken, String tokenSecret, String lastUrl) { 22 | this.requestToken = requestToken; 23 | this.tokenSecret = tokenSecret; 24 | this.lastUrl = lastUrl; 25 | } 26 | 27 | public String getRequestToken() { 28 | return requestToken; 29 | } 30 | 31 | public String getTokenSecret() { 32 | return tokenSecret; 33 | } 34 | 35 | public String getLastUrl() { 36 | return lastUrl; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/Message.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data; 2 | 3 | import com.googlecode.objectify.Key; 4 | 5 | import javax.persistence.Entity; 6 | import javax.persistence.Id; 7 | import javax.persistence.Transient; 8 | import java.util.Date; 9 | 10 | /** 11 | * A single message on a room board. 12 | * 13 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 14 | */ 15 | @Entity 16 | public class Message { 17 | @Id 18 | private Long id; 19 | 20 | @JsonHide 21 | private Key authorKey; 22 | @JsonHide 23 | private Key roomKey; // belongs to. 24 | 25 | private Date postedOn; 26 | private String text; 27 | private boolean isTweet; 28 | 29 | private Long attachmentId; 30 | 31 | @Transient 32 | private User author; 33 | 34 | public Long getId() { 35 | return id; 36 | } 37 | 38 | public void setId(Long id) { 39 | this.id = id; 40 | } 41 | 42 | public Key getAuthorKey() { 43 | return authorKey; 44 | } 45 | 46 | public Key getRoomKey() { 47 | return roomKey; 48 | } 49 | 50 | public User getAuthor() { 51 | return author; 52 | } 53 | 54 | public Long getAttachmentId() { 55 | return attachmentId; 56 | } 57 | 58 | public void setAttachment(Long attachmentId) { 59 | this.attachmentId = attachmentId; 60 | } 61 | 62 | public void setAuthor(User author) { 63 | this.author = author; 64 | this.authorKey = new Key(User.class, author.getUsername()); 65 | } 66 | 67 | public void setRoom(Room room) { 68 | this.roomKey = new Key(Room.class, room.getId()); 69 | } 70 | 71 | public Date getPostedOn() { 72 | return postedOn; 73 | } 74 | 75 | public void setPostedOn(Date postedOn) { 76 | this.postedOn = postedOn; 77 | } 78 | 79 | public String getText() { 80 | return text; 81 | } 82 | 83 | public void setText(String text) { 84 | this.text = text; 85 | } 86 | 87 | public boolean isTweet() { 88 | return isTweet; 89 | } 90 | 91 | public void setTweet(boolean tweet) { 92 | isTweet = tweet; 93 | } 94 | 95 | @Override 96 | public String toString() { 97 | return "Message{" + 98 | "id=" + id + 99 | ", author=" + authorKey + 100 | ", room=" + roomKey + 101 | ", postedOn=" + postedOn + 102 | ", text='" + text + '\'' + 103 | '}'; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/Occupancy.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data; 2 | 3 | import com.google.common.collect.Lists; 4 | import com.google.common.collect.Sets; 5 | import com.googlecode.objectify.Key; 6 | import com.googlecode.objectify.annotation.Cached; 7 | 8 | import javax.persistence.Embedded; 9 | import javax.persistence.Entity; 10 | import javax.persistence.Id; 11 | import javax.persistence.Transient; 12 | import java.util.Date; 13 | import java.util.List; 14 | import java.util.Set; 15 | 16 | /** 17 | * The occupancy state of a room. Current and unique. 18 | * 19 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 20 | */ 21 | @Cached @Entity 22 | public class Occupancy { 23 | public static final int TIME_SEGMENT_INTERVAL_MINS = 5; 24 | @Id 25 | private Long id; 26 | 27 | private Set> users = Sets.newLinkedHashSet(); 28 | 29 | @Embedded 30 | private List segments = Lists.newArrayList(); 31 | 32 | private Set terms = Sets.newLinkedHashSet(); 33 | 34 | @Transient @JsonHide 35 | private int maxActivity = -1; // memo field. 36 | 37 | public Long getId() { 38 | return id; 39 | } 40 | 41 | public void setId(Long id) { 42 | this.id = id; 43 | } 44 | 45 | public void add(User user) { 46 | users.add(new Key(User.class, user.getUsername())); 47 | } 48 | 49 | public Set> getUsers() { 50 | return users; 51 | } 52 | 53 | public List getSegments() { 54 | return segments; 55 | } 56 | 57 | public Set getTerms() { 58 | return terms; 59 | } 60 | 61 | public int getMaxActivity() { 62 | if (maxActivity == -1) { 63 | for (TimeSegment segment : segments) { 64 | if (segment.count > maxActivity) 65 | maxActivity = segment.count; 66 | } 67 | } 68 | 69 | return maxActivity; 70 | } 71 | 72 | /** 73 | * Picks a user at random (tries to be fair). 74 | */ 75 | public Key pickUser() { 76 | if (users.isEmpty()) { 77 | return null; 78 | } 79 | List> userKeys = Lists.newArrayList(); 80 | 81 | // Eliminate the anonymous user from the selection set. 82 | for (Key userKey : this.users) { 83 | if (!User.ANONYMOUS_USERNAME.equals(userKey.getName())) { 84 | userKeys.add(userKey); 85 | } 86 | } 87 | 88 | return pickUser(userKeys); 89 | } 90 | 91 | private Key pickUser(List> userKeys) { 92 | if (userKeys.isEmpty()) { 93 | return null; 94 | } 95 | return userKeys.get((int) ((Math.random() * userKeys.size()) % userKeys.size())); 96 | } 97 | 98 | @SuppressWarnings("deprecation") // Calendar is just too awful to use. 99 | public void incrementNow() { 100 | // first determine if a new time segment is needed. 101 | Date now = new Date(); 102 | int slot = now.getMinutes() / TIME_SEGMENT_INTERVAL_MINS; 103 | 104 | if (segments.isEmpty()) { 105 | // Insert a brand new time segment! 106 | segments.add(newSegment(now)); 107 | 108 | } else { 109 | TimeSegment timeSegment = segments.get(segments.size() - 1); 110 | int prevSlot = timeSegment.getStartsOn().getMinutes() / TIME_SEGMENT_INTERVAL_MINS; 111 | if (slot > prevSlot || slot == 0) { 112 | // This is a new time segment! 113 | segments.add(newSegment(now)); 114 | } else { 115 | // Increment the last segment. 116 | timeSegment.count++; 117 | } 118 | } 119 | } 120 | 121 | private static TimeSegment newSegment(Date now) { 122 | TimeSegment newSegment = new TimeSegment(); 123 | newSegment.startsOn = now; 124 | newSegment.count = 1; 125 | return newSegment; 126 | } 127 | 128 | public static class TimeSegment { 129 | private Date startsOn; 130 | private int count; 131 | 132 | public Date getStartsOn() { 133 | return startsOn; 134 | } 135 | 136 | public int getCount() { 137 | return count; 138 | } 139 | 140 | @Override 141 | public String toString() { 142 | return startsOn + ":" + count; 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/Room.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data; 2 | 3 | import com.googlecode.objectify.annotation.Cached; 4 | 5 | import javax.persistence.Entity; 6 | import javax.persistence.Id; 7 | import javax.persistence.Transient; 8 | import java.util.Date; 9 | 10 | /** 11 | * Encapuslates a structured set of documents. Contains an ACL. 12 | * 13 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 14 | */ 15 | @Cached @Entity 16 | public class Room { 17 | private static final Room DEFAULT; 18 | 19 | static { 20 | DEFAULT = new Room(); 21 | DEFAULT.setId(1L); 22 | DEFAULT.setName("Home"); 23 | DEFAULT.setOccupancy(new Occupancy()); 24 | } 25 | 26 | @Id 27 | private Long id; 28 | private String name; 29 | private String displayName; 30 | private String host; 31 | 32 | private Date startTime; 33 | private Date endTime; 34 | 35 | // Loaded independently. 36 | @Transient 37 | private Occupancy occupancy; 38 | 39 | public Long getId() { 40 | return id; 41 | } 42 | 43 | public void setId(Long id) { 44 | this.id = id; 45 | } 46 | 47 | public String getName() { 48 | return name; 49 | } 50 | 51 | public void setName(String name) { 52 | setDisplayName(name); 53 | 54 | name = name.toLowerCase().replaceAll("[ :?'\",.()]+", "-"); 55 | this.name = name; 56 | } 57 | 58 | public String getDisplayName() { 59 | return displayName; 60 | } 61 | 62 | public void setDisplayName(String displayName) { 63 | this.displayName = displayName; 64 | } 65 | 66 | public Occupancy getOccupancy() { 67 | return occupancy; 68 | } 69 | 70 | public void setOccupancy(Occupancy occupancy) { 71 | this.occupancy = occupancy; 72 | } 73 | 74 | public String getHost() { 75 | return host; 76 | } 77 | 78 | public void setHost(String host) { 79 | this.host = host; 80 | } 81 | 82 | public void setPeriod(Date startTime, Date endTime) { 83 | this.startTime = startTime; 84 | this.endTime = endTime; 85 | } 86 | 87 | public Date getStartTime() { 88 | return startTime; 89 | } 90 | 91 | public Date getEndTime() { 92 | return endTime; 93 | } 94 | 95 | @Override 96 | public boolean equals(Object o) { 97 | if (this == o) return true; 98 | if (!(o instanceof Room)) return false; 99 | 100 | Room room = (Room) o; 101 | 102 | if (id != null ? !id.equals(room.id) : room.id != null) return false; 103 | 104 | return true; 105 | } 106 | 107 | @Override 108 | public int hashCode() { 109 | return id != null ? id.hashCode() : 0; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/RoomTextIndex.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data; 2 | 3 | import com.google.common.collect.Lists; 4 | import com.google.gson.annotations.SerializedName; 5 | import com.googlecode.objectify.annotation.Cached; 6 | 7 | import javax.persistence.Embedded; 8 | import javax.persistence.Entity; 9 | import javax.persistence.Id; 10 | import java.io.Serializable; 11 | import java.util.List; 12 | 13 | /** 14 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 15 | */ 16 | @Cached @Entity 17 | public class RoomTextIndex { 18 | @Id 19 | private Long id; 20 | 21 | @Embedded 22 | private List words = Lists.newArrayList(); 23 | 24 | public void setId(long id) { 25 | this.id = id; 26 | } 27 | 28 | public static class WordTuple implements Comparable, Serializable { 29 | private static final long serialVersionUID = 0L; 30 | 31 | @SerializedName("title") 32 | private String word; 33 | @SerializedName("rank") 34 | private int count; 35 | @SerializedName("room") 36 | private String roomName; 37 | 38 | public String getWord() { 39 | return word; 40 | } 41 | 42 | public int getCount() { 43 | return count; 44 | } 45 | 46 | public int compareTo(WordTuple that) { 47 | return that.count - this.count; 48 | } 49 | 50 | @Override 51 | public boolean equals(Object o) { 52 | if (this == o) return true; 53 | if (!(o instanceof WordTuple)) return false; 54 | 55 | WordTuple that = (WordTuple) o; 56 | return that.word.equals(this.word); 57 | } 58 | 59 | @Override 60 | public int hashCode() { 61 | return word != null ? word.hashCode() : 0; 62 | } 63 | 64 | public void set(String key, Integer value) { 65 | this.word = key; 66 | this.count = value; 67 | } 68 | 69 | @Override 70 | public String toString() { 71 | return word + '(' + count + ')'; 72 | } 73 | 74 | public void setCount(int count) { 75 | this.count = count; 76 | } 77 | 78 | public void setRoomName(String roomName) { 79 | this.roomName = roomName; 80 | } 81 | } 82 | 83 | public void setRoom(Room room) { 84 | this.id = room.getId(); 85 | } 86 | 87 | public List getWords() { 88 | return words; 89 | } 90 | 91 | public void setWords(List words) { 92 | this.words = words; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/User.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data; 2 | 3 | import com.google.appengine.api.users.UserService; 4 | import com.googlecode.objectify.Key; 5 | import com.googlecode.objectify.annotation.Unindexed; 6 | 7 | import javax.persistence.Entity; 8 | import javax.persistence.Id; 9 | import java.util.Date; 10 | 11 | /** 12 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 13 | */ 14 | @Entity 15 | public class User { 16 | @Id 17 | private String username; // twitter username (unique id), hmm... 18 | private String displayName; 19 | private Date createdOn; 20 | 21 | @Unindexed 22 | private String avatar; // URL 23 | 24 | @Unindexed @JsonHide 25 | private String twitterAccessToken; 26 | 27 | @Unindexed @JsonHide 28 | private String twitterTokenSecret; 29 | 30 | @JsonHide 31 | private String sessionId; 32 | 33 | // Set up the anonymous user (special case) 34 | public static final String ANONYMOUS_USERNAME = "anonymous"; 35 | public static transient final Key ANONYMOUS_KEY = new Key(User.class, 36 | ANONYMOUS_USERNAME); 37 | 38 | // A reusable, mutable anonymous user. 39 | public static User anonymous() { 40 | User user = new User(); 41 | user.setUsername(ANONYMOUS_USERNAME); 42 | user.setAvatar(""); 43 | user.setCreatedOn(new Date(0)); 44 | user.setDisplayName("Lurker"); 45 | return user; 46 | } 47 | 48 | public boolean isGhost() { 49 | return twitterAccessToken == null; 50 | } 51 | 52 | public String getTwitterAccessToken() { 53 | return twitterAccessToken; 54 | } 55 | 56 | public void setTwitterAccessToken(String twitterAccessToken) { 57 | this.twitterAccessToken = twitterAccessToken; 58 | } 59 | 60 | public String getTwitterTokenSecret() { 61 | return twitterTokenSecret; 62 | } 63 | 64 | public void setTwitterTokenSecret(String twitterTokenSecret) { 65 | this.twitterTokenSecret = twitterTokenSecret; 66 | } 67 | 68 | public String getAvatar() { 69 | return avatar; 70 | } 71 | 72 | public void setAvatar(String avatar) { 73 | this.avatar = avatar; 74 | } 75 | 76 | public String getUsername() { 77 | return username; 78 | } 79 | 80 | public void setUsername(String username) { 81 | this.username = username; 82 | } 83 | 84 | public String getDisplayName() { 85 | return displayName; 86 | } 87 | 88 | public void setDisplayName(String displayName) { 89 | this.displayName = displayName; 90 | } 91 | 92 | public Date getCreatedOn() { 93 | return createdOn; 94 | } 95 | 96 | public void setCreatedOn(Date createdOn) { 97 | this.createdOn = createdOn; 98 | } 99 | 100 | public void setSessionId(String sessionId) { 101 | this.sessionId = sessionId; 102 | } 103 | 104 | @Override 105 | public boolean equals(Object o) { 106 | if (this == o) return true; 107 | if (!(o instanceof User)) return false; 108 | 109 | User user = (User) o; 110 | 111 | if (username != null ? !username.equals(user.username) : user.username != null) return false; 112 | 113 | return true; 114 | } 115 | 116 | @Override 117 | public int hashCode() { 118 | return username != null ? username.hashCode() : 0; 119 | } 120 | 121 | public static User named(UserService userService) { 122 | User user = new User(); 123 | user.setUsername(userService.getCurrentUser().getNickname()); 124 | user.setDisplayName(user.getUsername()); 125 | return user; 126 | } 127 | 128 | @Override 129 | public String toString() { 130 | return "<" + username + ">"; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/buzz/BuzzSearch.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data.buzz; 2 | 3 | import com.google.common.collect.Lists; 4 | import com.google.gson.annotations.SerializedName; 5 | import com.wideplay.crosstalk.data.Message; 6 | import com.wideplay.crosstalk.data.User; 7 | 8 | import java.util.Date; 9 | import java.util.List; 10 | 11 | /** 12 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 13 | */ 14 | public class BuzzSearch { 15 | private List items = Lists.newArrayList(); 16 | 17 | public List getItems() { 18 | return items; 19 | } 20 | 21 | public Message pick() { 22 | if (items.isEmpty()) { 23 | return null; 24 | } 25 | 26 | // Pick a random buzz. 27 | Buzz buzz = items.get((int) ((Math.random() * items.size()) % items.size())); 28 | 29 | return toMessage(buzz); 30 | } 31 | 32 | public static Message toMessage(Buzz buzz) { 33 | Message message = new Message(); 34 | message.setId((long) buzz.id.hashCode()); // UGH HACK. 35 | message.setText(buzz.title); 36 | message.setPostedOn(new Date()); // set properly 37 | message.setTweet(true); 38 | 39 | User author = new User(); 40 | author.setAvatar(buzz.actor.getAvatar()); 41 | author.setUsername(buzz.actor.getName()); 42 | author.setDisplayName(buzz.actor.getName()); 43 | message.setAuthor(author); 44 | 45 | return message; 46 | } 47 | 48 | public static class Data { 49 | private BuzzSearch data; 50 | 51 | public BuzzSearch getData() { 52 | return data; 53 | } 54 | } 55 | 56 | public static class Buzz { 57 | private Actor actor; 58 | private String id; 59 | private String title; 60 | 61 | private Links links; 62 | 63 | public String getArbitraryPermalink() { 64 | return links.alternate.isEmpty() ? null : links.alternate.get(0).href; 65 | } 66 | 67 | public Actor getActor() { 68 | return actor; 69 | } 70 | 71 | public String getId() { 72 | return id; 73 | } 74 | } 75 | 76 | public static class Links { 77 | private List liked = Lists.newArrayList(); 78 | private List alternate = Lists.newArrayList(); 79 | } 80 | 81 | public static class Alternate { 82 | private String href; 83 | private String type; 84 | private String count; 85 | } 86 | 87 | public static class Actor { 88 | private String name; 89 | 90 | @SerializedName("thumbnailUrl") 91 | private String avatar; 92 | 93 | public String getName() { 94 | return name; 95 | } 96 | 97 | public String getAvatar() { 98 | return avatar; 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/buzz/BuzzUser.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data.buzz; 2 | 3 | import com.google.gson.annotations.SerializedName; 4 | 5 | /** 6 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 7 | */ 8 | public class BuzzUser { 9 | @SerializedName("thumbnailUrl") 10 | private String avatar; 11 | 12 | private String displayName; 13 | 14 | public String getAvatar() { 15 | return avatar; 16 | } 17 | 18 | public String getDisplayName() { 19 | return displayName; 20 | } 21 | 22 | public static class Data { 23 | @SerializedName("data") 24 | private BuzzUser user; 25 | 26 | public BuzzUser getUser() { 27 | return user; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/indexing/StopWords.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data.indexing; 2 | 3 | import com.google.common.collect.ImmutableSet; 4 | import com.google.inject.Singleton; 5 | import org.apache.commons.io.IOUtils; 6 | 7 | import java.io.IOException; 8 | import java.util.List; 9 | import java.util.Set; 10 | 11 | /** 12 | * A simple utility for identifying a fixed set of stopwords. 13 | * 14 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 15 | */ 16 | @Singleton 17 | public class StopWords { 18 | private final Set words; 19 | 20 | public StopWords() throws IOException { 21 | @SuppressWarnings("unchecked") 22 | List lines = IOUtils.readLines(StopWords.class.getResourceAsStream("stopwords.txt")); 23 | 24 | ImmutableSet.Builder builder = ImmutableSet.builder(); 25 | for (String line : lines) { 26 | if (line.isEmpty()) { 27 | continue; 28 | } 29 | builder.add(line); 30 | } 31 | this.words = builder.build(); 32 | } 33 | 34 | public boolean isStopWord(String word) { 35 | // Anything below 3 characters is automatically a stop word. 36 | if (word.length() < 4 && word.length() < 14) { 37 | return true; 38 | } 39 | 40 | return words.contains(word); 41 | } 42 | } -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/store/MessageStore.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data.store; 2 | 3 | import com.google.common.collect.Lists; 4 | import com.google.common.collect.Sets; 5 | import com.google.inject.Inject; 6 | import com.googlecode.objectify.Key; 7 | import com.googlecode.objectify.Objectify; 8 | import com.googlecode.objectify.Query; 9 | import com.wideplay.crosstalk.data.Attachment; 10 | import com.wideplay.crosstalk.data.Message; 11 | import com.wideplay.crosstalk.data.Room; 12 | import com.wideplay.crosstalk.data.User; 13 | 14 | import java.util.List; 15 | import java.util.Map; 16 | import java.util.Set; 17 | 18 | /** 19 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 20 | */ 21 | public class MessageStore { 22 | @Inject 23 | private Objectify objectify; 24 | 25 | @Inject 26 | private UserStore userStore; 27 | 28 | public Message fetchMessage(Long id) { 29 | return objectify.find(Message.class, id); 30 | } 31 | 32 | public void save(Message message) { 33 | objectify.put(message); 34 | } 35 | 36 | public List list(Room room) { 37 | // TODO dont load the entire list, instead paginate with cursors. 38 | List list = objectify 39 | .query(Message.class) 40 | .filter("roomKey", new Key(Room.class, room.getId())) 41 | .order("postedOn") 42 | .list(); 43 | resolveUsers(list); 44 | 45 | return list; 46 | } 47 | 48 | private void resolveUsers(List list) { 49 | Set> userKeys = Sets.newHashSet(); 50 | for (Message message : list) { 51 | userKeys.add(message.getAuthorKey()); 52 | } 53 | 54 | Map, User> users = userStore.resolve(userKeys); 55 | 56 | // Set these users back on the messages. This seems a bit expensive. =( 57 | for (Message message : list) { 58 | message.setAuthor(users.get(message.getAuthorKey())); 59 | } 60 | } 61 | 62 | public List listRecent(int max) { 63 | Query results = objectify.query(Message.class) 64 | .order("-postedOn"); 65 | 66 | List picks = Lists.newArrayList(); 67 | int i = 0; 68 | for (Message pick : results) { 69 | picks.add(pick); 70 | if (i > max) { 71 | break; 72 | } 73 | i++; 74 | } 75 | 76 | resolveUsers(picks); 77 | 78 | return picks; 79 | } 80 | 81 | public void save(Attachment attachment) { 82 | objectify.put(attachment); 83 | } 84 | 85 | public Attachment fetchAttachment(Long id) { 86 | return objectify.find(Attachment.class, id); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/store/RoomStore.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data.store; 2 | 3 | import com.google.common.collect.Lists; 4 | import com.google.inject.Inject; 5 | import com.google.inject.Provider; 6 | import com.google.inject.Singleton; 7 | import com.googlecode.objectify.Key; 8 | import com.googlecode.objectify.Objectify; 9 | import com.wideplay.crosstalk.data.ConnectedClients; 10 | import com.wideplay.crosstalk.data.Occupancy; 11 | import com.wideplay.crosstalk.data.Room; 12 | import com.wideplay.crosstalk.data.RoomTextIndex; 13 | import com.wideplay.crosstalk.data.User; 14 | 15 | import java.util.Collection; 16 | import java.util.List; 17 | import java.util.UUID; 18 | 19 | /** 20 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 21 | */ 22 | @Singleton 23 | public class RoomStore { 24 | 25 | @Inject 26 | private Objectify objectify; 27 | 28 | public RoomStore() { 29 | } 30 | 31 | public Room byId(Long id) { 32 | Room room = objectify.find(Room.class, id); 33 | if (room == null) { 34 | return null; 35 | } 36 | 37 | loadOccupancy(room); 38 | return room; 39 | } 40 | 41 | private void loadOccupancy(Room room) { 42 | // Grab the occupancy too (they share the same id). 43 | Occupancy occupancy = objectify.get(Occupancy.class, room.getId()); 44 | room.setOccupancy(occupancy); 45 | } 46 | 47 | @Inject 48 | private Provider connectedClientsProvider; 49 | private ConnectedClients loadConnectedClients() { 50 | return connectedClientsProvider.get(); 51 | } 52 | 53 | /** 54 | * This method is very special, since occupancy is such a high-write 55 | * data structure, we use a writeback cache instead of the normal 56 | * objectify-provided writethru cached backed on appengine's memcache. 57 | */ 58 | public void save(Occupancy occupancy) { 59 | 60 | // Overwrites the old occupancy! 61 | objectify.put(occupancy); 62 | } 63 | 64 | public Room named(String name) { 65 | Room room = objectify.query(Room.class).filter("name", name).get(); 66 | if (null == room) { 67 | return null; 68 | } 69 | 70 | // Load occupancy. 71 | loadOccupancy(room); 72 | return room; 73 | } 74 | 75 | public void remove(Long roomId) { 76 | // delete! (orphans both occupancy & posts). 77 | objectify.delete(Room.class, roomId); 78 | } 79 | 80 | public List list() { 81 | List list = objectify.query(Room.class).order("startTime").list(); 82 | for (Room room : list) { 83 | // Load the occupancies too. 84 | loadOccupancy(room); 85 | } 86 | return list; 87 | } 88 | 89 | 90 | public void create(Room room) { 91 | room.setId(UUID.randomUUID().getMostSignificantBits()); 92 | 93 | objectify.put(room); 94 | // Also create an occupancy with the same id. 95 | Occupancy occupancy = new Occupancy(); 96 | occupancy.setId(room.getId()); 97 | objectify.put(occupancy); 98 | } 99 | 100 | public RoomTextIndex indexOf(Room room) { 101 | return objectify.find(RoomTextIndex.class, room.getId()); 102 | } 103 | 104 | // Returns global index. 105 | public RoomTextIndex indexOf() { 106 | return objectify.find(RoomTextIndex.class, 1L); 107 | } 108 | 109 | public void save(RoomTextIndex index) { 110 | objectify.put(index); 111 | } 112 | 113 | public Collection roomsOf(User user) { 114 | Collection> roomKeys = loadConnectedClients().getRooms(user); 115 | List rooms = Lists.newArrayList(); 116 | for (Key roomKey : roomKeys) { 117 | rooms.add(byId(roomKey.getId())); 118 | } 119 | return rooms; 120 | } 121 | 122 | public void connectClient(User user, String token, Room room) { 123 | ConnectedClients clients = loadConnectedClients(); 124 | clients.add(token, user, room); 125 | } 126 | 127 | public Collection channelOf(Key user, Room room) { 128 | return loadConnectedClients().channelOf(user.getName(), room); 129 | } 130 | 131 | public void leaveRoom(Key user, Room room, String id) { 132 | ConnectedClients clients = loadConnectedClients(); 133 | boolean remove = clients.remove(user, room, id); 134 | 135 | // Did this user lose all occupancy? 136 | if (remove) { 137 | room.getOccupancy().getUsers().remove(user); 138 | save(room.getOccupancy()); 139 | } 140 | } 141 | 142 | public void leaveRoom(Key user, Room room) { 143 | ConnectedClients clients = loadConnectedClients(); 144 | clients.removeAll(user, room); 145 | 146 | // This user lost all occupancy of the room. 147 | room.getOccupancy().getUsers().remove(user); 148 | save(room.getOccupancy()); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/store/StoreModule.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data.store; 2 | 3 | import com.google.inject.AbstractModule; 4 | import com.google.inject.Provides; 5 | import com.google.inject.servlet.RequestScoped; 6 | import com.googlecode.objectify.Objectify; 7 | import com.googlecode.objectify.ObjectifyService; 8 | import com.wideplay.crosstalk.data.*; 9 | import com.wideplay.crosstalk.data.RoomTextIndex; 10 | 11 | /** 12 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 13 | */ 14 | public class StoreModule extends AbstractModule { 15 | static { 16 | ObjectifyService.register(Room.class); 17 | ObjectifyService.register(Occupancy.class); 18 | ObjectifyService.register(User.class); 19 | ObjectifyService.register(Message.class); 20 | ObjectifyService.register(Attachment.class); 21 | ObjectifyService.register(LoginToken.class); 22 | ObjectifyService.register(RoomTextIndex.class); 23 | ObjectifyService.register(ConnectedClients.UserRoom.class); 24 | } 25 | 26 | @Override 27 | protected void configure() { 28 | } 29 | 30 | @Provides 31 | @RequestScoped 32 | Objectify provideObjectify() { 33 | return ObjectifyService.begin(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/store/UserStore.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data.store; 2 | 3 | import com.google.inject.Inject; 4 | import com.google.inject.Singleton; 5 | import com.googlecode.objectify.Key; 6 | import com.googlecode.objectify.Objectify; 7 | import com.wideplay.crosstalk.data.LoginToken; 8 | import com.wideplay.crosstalk.data.User; 9 | 10 | import java.util.Map; 11 | import java.util.Set; 12 | 13 | /** 14 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 15 | */ 16 | @Singleton 17 | public class UserStore { 18 | 19 | @Inject 20 | private Objectify objectify; 21 | 22 | /** 23 | * Returns user if logged in, or null. 24 | */ 25 | public User isLoggedIn(String sessionId) { 26 | return objectify.query(User.class).filter("sessionId", sessionId).get(); 27 | } 28 | 29 | public void logout(String sessionId) { 30 | User user = isLoggedIn(sessionId); 31 | if (null != user) { 32 | user.setSessionId(null); 33 | objectify.put(user); 34 | } 35 | } 36 | 37 | public void loginAndMaybeCreate(String sessionId, User user) { 38 | User found = objectify.find(User.class, user.getUsername()); 39 | if (found == null) { 40 | found = user; 41 | } 42 | 43 | found.setSessionId(sessionId); 44 | objectify.put(found); 45 | } 46 | 47 | public Map, User> resolve(Set> users) { 48 | return objectify.get(users); 49 | } 50 | 51 | public LoginToken claimOAuthToken(String requestToken) { 52 | LoginToken token = objectify.find(LoginToken.class, requestToken); 53 | if (null == token) { 54 | return null; 55 | } 56 | objectify.delete(LoginToken.class, requestToken); 57 | return token; 58 | } 59 | 60 | public void newOAuthToken(String requestToken, String tokenSecret, String lastUrl) { 61 | objectify.put(new LoginToken(requestToken, tokenSecret, lastUrl)); 62 | } 63 | 64 | public User fetch(Key userKey) { 65 | return objectify.get(userKey); 66 | } 67 | 68 | public void createGhost(User author) { 69 | objectify.put(author); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/twitter/TwitterSearch.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data.twitter; 2 | 3 | import com.google.gson.annotations.SerializedName; 4 | import com.wideplay.crosstalk.data.Message; 5 | import com.wideplay.crosstalk.data.User; 6 | 7 | import java.util.Date; 8 | import java.util.List; 9 | 10 | /** 11 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 12 | */ 13 | public class TwitterSearch { 14 | private List results; 15 | 16 | public List getResults() { 17 | return results; 18 | } 19 | 20 | public Message pick() { 21 | if (results.isEmpty()) { 22 | return null; 23 | } 24 | 25 | // Pick a random tweet. 26 | Tweet tweet = results.get((int)((Math.random() * results.size()) % results.size())); 27 | 28 | Message message = new Message(); 29 | message.setId(tweet.id); 30 | message.setText(tweet.text); 31 | message.setPostedOn(new Date()); // set properly 32 | message.setTweet(true); 33 | 34 | User author = new User(); 35 | author.setAvatar(tweet.avatar); 36 | author.setUsername(tweet.username); 37 | author.setDisplayName(tweet.username); 38 | message.setAuthor(author); 39 | 40 | return message; 41 | } 42 | 43 | public static class Tweet { 44 | @SerializedName("id_str") 45 | private Long id; 46 | 47 | @SerializedName("from_user") 48 | private String username; 49 | 50 | @SerializedName("profile_image_url") 51 | private String avatar; 52 | 53 | @SerializedName("created_at") 54 | private String postedOn; 55 | 56 | private String text; 57 | 58 | public String getUsername() { 59 | return username; 60 | } 61 | 62 | public String getAvatar() { 63 | return avatar; 64 | } 65 | 66 | public String getPostedOn() { 67 | return postedOn; 68 | } 69 | 70 | public String getText() { 71 | return text; 72 | } 73 | 74 | @Override 75 | public String toString() { 76 | return "Tweet{" + 77 | "username='" + username + '\'' + 78 | ", avatar='" + avatar + '\'' + 79 | ", postedOn='" + postedOn + '\'' + 80 | ", text='" + text + '\'' + 81 | '}'; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/data/twitter/TwitterUser.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data.twitter; 2 | 3 | import com.google.gson.annotations.SerializedName; 4 | 5 | /** 6 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 7 | */ 8 | public class TwitterUser { 9 | private String location; 10 | 11 | @SerializedName("profile_image_url") 12 | private String profileImageUrl; 13 | 14 | @SerializedName("screen_name") 15 | private String screenName; 16 | 17 | private String name; 18 | 19 | public String getLocation() { 20 | return location; 21 | } 22 | 23 | public String getProfileImageUrl() { 24 | return profileImageUrl; 25 | } 26 | 27 | public String getScreenName() { 28 | return screenName; 29 | } 30 | 31 | public String getName() { 32 | return name; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/AsyncIndexService.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web; 2 | 3 | import com.google.common.collect.ImmutableMap; 4 | import com.google.gson.Gson; 5 | import com.google.inject.Inject; 6 | import com.google.sitebricks.At; 7 | import com.google.sitebricks.headless.Reply; 8 | import com.google.sitebricks.headless.Service; 9 | import com.google.sitebricks.http.Post; 10 | import com.wideplay.crosstalk.CrosstalkModule; 11 | import com.wideplay.crosstalk.data.Occupancy; 12 | import com.wideplay.crosstalk.data.Room; 13 | import com.wideplay.crosstalk.data.store.RoomStore; 14 | 15 | import java.text.DateFormat; 16 | import java.text.SimpleDateFormat; 17 | import java.util.List; 18 | 19 | /** 20 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 21 | */ 22 | @At("/r/async/index") @Service 23 | public class AsyncIndexService { 24 | private final DateFormat messageDateFormat = 25 | new SimpleDateFormat(CrosstalkModule.SEGMENT_DATE_FORMAT); 26 | 27 | @Inject 28 | private RoomStore roomStore; 29 | 30 | @Post 31 | Reply renderActivityBubbles(ClientRequest request, Gson gson) { 32 | Long roomId = Long.valueOf(request.getRoom()); 33 | Room room = roomStore.byId(roomId); 34 | 35 | List segments = room.getOccupancy().getSegments(); 36 | if (segments.isEmpty()) { 37 | return Reply.with(""); 38 | } 39 | 40 | StringBuilder builder = new StringBuilder(); 41 | for (Occupancy.TimeSegment segment : segments) { 42 | builder.append("
"); 49 | builder.append(RoomPage.renderActivity(room, segment)); 50 | builder.append("
"); 51 | } 52 | 53 | return Reply.with(gson.toJson(ImmutableMap.of( 54 | "html", builder.toString() 55 | ))).type("application/json"); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/AsyncPostService.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web; 2 | 3 | import com.google.common.collect.ImmutableMap; 4 | import com.google.common.collect.Lists; 5 | import com.google.gson.Gson; 6 | import com.google.inject.Inject; 7 | import com.google.sitebricks.At; 8 | import com.google.sitebricks.client.Web; 9 | import com.google.sitebricks.headless.Reply; 10 | import com.google.sitebricks.headless.Service; 11 | import com.google.sitebricks.http.Post; 12 | import com.wideplay.crosstalk.data.Message; 13 | import com.wideplay.crosstalk.data.Occupancy; 14 | import com.wideplay.crosstalk.data.Room; 15 | import com.wideplay.crosstalk.data.RoomTextIndex; 16 | import com.wideplay.crosstalk.data.User; 17 | import com.wideplay.crosstalk.data.store.MessageStore; 18 | import com.wideplay.crosstalk.data.store.RoomStore; 19 | import com.wideplay.crosstalk.web.auth.Secure; 20 | import org.slf4j.Logger; 21 | import org.slf4j.LoggerFactory; 22 | 23 | import java.net.URLEncoder; 24 | import java.util.Date; 25 | import java.util.List; 26 | import java.util.UUID; 27 | 28 | /** 29 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 30 | */ 31 | @At("/r/async") @Service 32 | public class AsyncPostService { 33 | private static final Logger log = LoggerFactory.getLogger(AsyncPostService.class); 34 | public static final int MAX_WORDS = 20; 35 | 36 | @Inject 37 | private CurrentUser currentUser; 38 | 39 | @Inject 40 | private RoomStore roomStore; 41 | 42 | @Inject 43 | private MessageStore messageStore; 44 | 45 | @Inject 46 | private Gson gson; 47 | 48 | @Inject 49 | private Web web; 50 | 51 | @Inject 52 | private Broadcaster broadcaster; 53 | 54 | @At("/message") @Post @Secure 55 | Reply receiveMessage(ClientRequest request) { 56 | Room room = roomStore.byId(request.getRoom()); 57 | 58 | Message message = new Message(); 59 | message.setId(UUID.randomUUID().getMostSignificantBits()); 60 | message.setText(request.getText()); 61 | message.setRoom(room); 62 | message.setPostedOn(new Date()); 63 | if (request.getAttachmentId() != null) { 64 | message.setAttachment(request.getAttachmentId()); 65 | } 66 | 67 | // Temporary hack while we dont have proper user db. 68 | User author = currentUser.getUser(); 69 | message.setAuthor(author); 70 | 71 | log.info("Received post {}", message); 72 | 73 | // Reflect to other clients. 74 | String json = gson.toJson(ImmutableMap.of( 75 | "rpc", "receive", 76 | "post", message 77 | )); 78 | broadcaster.broadcast(room, author, json); 79 | 80 | // Save AFTER broadcast (reduces latency). This is used for activity segments. 81 | room.getOccupancy().incrementNow(); // Increment activity in the room by 1. 82 | roomStore.save(room.getOccupancy()); 83 | messageStore.save(message); 84 | 85 | return Reply.saying().ok(); 86 | } 87 | 88 | @At("/join") @Post 89 | Reply joinRoom(ClientRequest request) { 90 | Room room = roomStore.byId(request.getRoom()); 91 | 92 | User joiner = currentUser.getUser(); 93 | log.debug("Received join notification: {}", joiner.getUsername()); 94 | String json = gson.toJson(ImmutableMap.of( 95 | "rpc", "join", 96 | "joiner", joiner 97 | )); 98 | broadcaster.broadcast(room, joiner, json); 99 | 100 | // This is a bit hacky, but we update occupancy BEFORE this RPC is called 101 | // in the home screen. We should move it here. 102 | 103 | return Reply.saying().ok(); 104 | } 105 | 106 | @At("/leave") @Post 107 | Reply leaveRoom(ClientRequest request) { 108 | Room room = roomStore.byId(request.getRoom()); 109 | 110 | User leaver = currentUser.getUser(); 111 | log.info("Received leave notification: {}", leaver.getUsername()); 112 | String json = gson.toJson(ImmutableMap.of( 113 | "rpc", "leave", 114 | "leaver", leaver 115 | )); 116 | broadcaster.broadcast(room, leaver, json); 117 | 118 | return Reply.saying().ok(); 119 | } 120 | 121 | @At("/ping") @Post 122 | Reply ping(ClientRequest request, CurrentUser currentUser) { 123 | String hashtag = URLEncoder.encode("#webstock"); 124 | 125 | // Update active status timestamp of this user/connection 126 | //... 127 | 128 | return Reply.saying().ok(); 129 | } 130 | 131 | @At("/add-term") @Post @Secure 132 | Reply addTerm(ClientRequest request) { 133 | 134 | Room room = roomStore.byId(request.getRoom()); 135 | Occupancy occupancy = room.getOccupancy(); 136 | String term = request.getText(); 137 | if (!occupancy.getTerms().contains(term)) { 138 | occupancy.getTerms().add(term); 139 | roomStore.save(occupancy); 140 | log.info("New term added {} in room {}", term, room.getName()); 141 | } 142 | 143 | return Reply.saying().ok(); 144 | } 145 | 146 | @At("/remove-term") @Post @Secure 147 | Reply removeTerm(ClientRequest request) { 148 | 149 | Room room = roomStore.byId(request.getRoom()); 150 | Occupancy occupancy = room.getOccupancy(); 151 | String term = request.getText(); 152 | if (occupancy.getTerms().contains(term)) { 153 | occupancy.getTerms().remove(term); 154 | roomStore.save(occupancy); 155 | log.info("Term deleted {} in room {}", term, room.getName()); 156 | } 157 | 158 | return Reply.saying().ok(); 159 | } 160 | 161 | @At("/topics") @Post @Secure 162 | Reply topics() { 163 | RoomTextIndex index = roomStore.indexOf(); 164 | if (null == index) { 165 | return Reply.with("[]"); 166 | } 167 | List words = Lists.newArrayList(index.getWords()); 168 | // Rerank into 5 buckets. 169 | if (words.size() > MAX_WORDS) { 170 | words = words.subList(0, MAX_WORDS); 171 | } 172 | words.get(0).setCount(1); 173 | 174 | for (int i = 1; i < MAX_WORDS && i < words.size(); i++) { 175 | RoomTextIndex.WordTuple wordTuple = words.get(i); 176 | int bucket = (i / 5) + 2; 177 | wordTuple.setCount(bucket); 178 | } 179 | 180 | return Reply.with(gson.toJson(words)); 181 | } 182 | 183 | @At("/random_msg") @Post @Secure 184 | Reply randomMessage() { 185 | List messages = messageStore.listRecent(10); //get the last 10 186 | if (messages.isEmpty()) { 187 | return Reply.with("{}"); 188 | } 189 | Message msg = messages.get((int) ((Math.random() * messages.size()) % messages.size())); 190 | return Reply.with(gson.toJson(msg)); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/AttachmentService.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web; 2 | 3 | import com.google.inject.Inject; 4 | import com.google.inject.name.Named; 5 | import com.google.sitebricks.At; 6 | import com.google.sitebricks.headless.Reply; 7 | import com.google.sitebricks.headless.Service; 8 | import com.google.sitebricks.http.Get; 9 | import com.wideplay.crosstalk.data.Attachment; 10 | import com.wideplay.crosstalk.data.store.MessageStore; 11 | import org.apache.commons.io.IOUtils; 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | 15 | import javax.servlet.ServletOutputStream; 16 | import javax.servlet.http.HttpServletResponse; 17 | import java.io.IOException; 18 | 19 | /** 20 | * Serves attachments. Complement to Upload. 21 | * 22 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 23 | */ 24 | @At("/r/attachment/:id") @Service 25 | public class AttachmentService { 26 | private static final Logger log = LoggerFactory.getLogger(AttachmentService.class); 27 | 28 | @Inject 29 | private CurrentUser currentUser; 30 | 31 | @Inject 32 | private MessageStore messageStore; 33 | 34 | @Get 35 | Reply sendFile(@Named("id") String id, HttpServletResponse response) throws IOException { 36 | Long attachmentId = Long.valueOf(id); 37 | 38 | Attachment attachment = messageStore.fetchAttachment(attachmentId); 39 | if (null == attachment) { 40 | log.warn("No attachment found for id {}", attachmentId); 41 | return Reply.saying().notFound(); 42 | } 43 | 44 | byte[] bytes = attachment.getContent().getBytes(); 45 | response.setContentType(attachment.getMimeType()); 46 | response.setContentLength(bytes.length); 47 | ServletOutputStream out = response.getOutputStream(); 48 | IOUtils.write(bytes, out); 49 | IOUtils.closeQuietly(out); 50 | 51 | return Reply.saying().ok(); 52 | } 53 | 54 | private String determineMimeType(String fileName) { 55 | if (fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")) { 56 | return "image/jpeg"; 57 | } else if (fileName.endsWith(".gif")) { 58 | return "image/gif"; 59 | } else if (fileName.endsWith(".png")) { 60 | return "image/png"; 61 | } 62 | // Unknown. 63 | return "application/octet-stream"; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/Broadcaster.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web; 2 | 3 | import com.google.appengine.api.channel.ChannelMessage; 4 | import com.google.appengine.api.channel.ChannelService; 5 | import com.google.common.collect.ImmutableList; 6 | import com.google.inject.Inject; 7 | import com.google.inject.Singleton; 8 | import com.googlecode.objectify.Key; 9 | import com.wideplay.crosstalk.data.Room; 10 | import com.wideplay.crosstalk.data.User; 11 | import com.wideplay.crosstalk.data.store.RoomStore; 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | 15 | import java.util.Collection; 16 | 17 | /** 18 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 19 | */ 20 | @Singleton 21 | public class Broadcaster { 22 | private static final Logger log = LoggerFactory.getLogger(Broadcaster.class); 23 | @Inject 24 | private RoomStore roomStore; 25 | 26 | @Inject 27 | private ChannelService channel; 28 | 29 | public void broadcast(Room room, User author, String json) { 30 | for (Key user : ImmutableList.copyOf(room.getOccupancy().getUsers())) { 31 | if (null != author && user.getName().equals(author.getUsername())) 32 | continue; 33 | 34 | Collection channelIds = roomStore.channelOf(user, room); 35 | log.info("Broadcasting packet to {} [{}]\n", user.getName() + "/" + channelIds, json); 36 | 37 | if (null != channelIds) { 38 | for (String id : channelIds) { 39 | log.info("Sending packet to {} [{}]\n", user.getName() + "/" + id, json); 40 | try { 41 | channel.sendMessage(new ChannelMessage(id, json)); 42 | } catch (Exception e) { 43 | log.error("Encountered exception during broadcast.", e); 44 | 45 | // Evict user (probably a stale channel id) 46 | log.info("Evicing user {}", user.getName()); 47 | roomStore.leaveRoom(user, room, id); 48 | } 49 | } 50 | } else { 51 | log.info("Stale occupancy detected, evicing user {}", user.getName()); 52 | // stale occupancy, remove from room... 53 | // roomStore.leaveRoom(user, room); 54 | } 55 | } 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/ClientRequest.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web; 2 | 3 | /** 4 | * Value object to receive JSON data from client. 5 | * 6 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 7 | */ 8 | public class ClientRequest { 9 | private String text; 10 | private String token; 11 | private Long room; 12 | private Long attachmentId; 13 | 14 | public Long getRoom() { 15 | return room; 16 | } 17 | 18 | public String getToken() { 19 | return token; 20 | } 21 | 22 | public String getText() { 23 | return text; 24 | } 25 | 26 | @Override 27 | public String toString() { 28 | return "ClientRequest{" + 29 | ", text='" + text + '\'' + 30 | '}'; 31 | } 32 | 33 | public Long getAttachmentId() { 34 | return attachmentId; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/CurrentUser.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web; 2 | 3 | import com.google.inject.Inject; 4 | import com.google.inject.servlet.RequestScoped; 5 | import com.wideplay.crosstalk.data.User; 6 | 7 | import javax.servlet.http.Cookie; 8 | import javax.servlet.http.HttpServletRequest; 9 | import java.util.UUID; 10 | 11 | /** 12 | * Represents the current user for this request. 13 | * 14 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 15 | */ 16 | @RequestScoped 17 | public class CurrentUser { 18 | public static final String SESSION_COOKIE_NAME = "x-crosstalk-session-id"; 19 | 20 | @Inject 21 | private HttpServletRequest request; 22 | 23 | private User user; 24 | 25 | public User getUser() { 26 | return user; 27 | } 28 | 29 | public void setUser(User user) { 30 | this.user = user; 31 | } 32 | 33 | public String newSessionId() { 34 | return UUID.randomUUID().toString(); 35 | } 36 | 37 | public boolean isAnonymous() { 38 | return user != null && User.ANONYMOUS_USERNAME.equals(user.getUsername()); 39 | } 40 | 41 | public Cookie getSessionCookie() { 42 | Cookie[] cookies = request.getCookies(); 43 | if (null == cookies) { 44 | return null; 45 | } 46 | 47 | for (Cookie cookie : cookies) { 48 | if (SESSION_COOKIE_NAME.equals(cookie.getName())) { 49 | return cookie; 50 | } 51 | } 52 | 53 | return null; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/HomePage.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web; 2 | 3 | import com.google.common.collect.ImmutableList; 4 | import com.google.common.collect.Lists; 5 | import com.google.inject.Inject; 6 | import com.google.sitebricks.At; 7 | import com.google.sitebricks.Show; 8 | import com.google.sitebricks.http.Get; 9 | import com.googlecode.objectify.Key; 10 | import com.wideplay.crosstalk.data.Room; 11 | import com.wideplay.crosstalk.data.RoomTextIndex; 12 | import com.wideplay.crosstalk.data.User; 13 | import com.wideplay.crosstalk.data.store.RoomStore; 14 | import com.wideplay.crosstalk.data.store.UserStore; 15 | 16 | import javax.servlet.http.HttpServletResponse; 17 | import java.text.DateFormat; 18 | import java.text.SimpleDateFormat; 19 | import java.util.Collection; 20 | import java.util.Date; 21 | import java.util.Iterator; 22 | import java.util.List; 23 | import java.util.Set; 24 | 25 | /** 26 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 27 | */ 28 | @At("/r/home") @Show("HomePage.xml") 29 | public class HomePage { 30 | @Inject 31 | private RoomStore roomStore; 32 | 33 | @Inject 34 | private UserStore userStore; 35 | 36 | private List rooms; 37 | 38 | private DateFormat timeFormat = new SimpleDateFormat("hh:mm"); 39 | private DateFormat dayFormat = new SimpleDateFormat(", MMM dd"); 40 | private DateFormat timestampFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm"); 41 | 42 | @Get 43 | void displayHome(HttpServletResponse response) { 44 | response.setContentType("text/html; charset=utf-8"); 45 | 46 | rooms = Lists.newArrayList(roomStore.list()); 47 | 48 | // Remove the JAPAC special room. 49 | Iterator iterator = rooms.iterator(); 50 | while (iterator.hasNext()) { 51 | Room room = iterator.next(); 52 | if (room.getName().equals("japac")) { 53 | iterator.remove(); 54 | break; 55 | } 56 | } 57 | } 58 | 59 | public List getRooms() { 60 | return rooms; 61 | } 62 | 63 | public Collection occupants(Room room) { 64 | return userStore.resolve(room.getOccupancy().getUsers()).values(); 65 | } 66 | 67 | public int contributors(Room room) { 68 | Set> users = room.getOccupancy().getUsers(); 69 | if (users.contains(User.ANONYMOUS_KEY)) { 70 | return users.size() - 1; 71 | } 72 | 73 | return users.size(); 74 | } 75 | 76 | public List trends(Room room) { 77 | RoomTextIndex index = roomStore.indexOf(room); 78 | if (null == index) { 79 | return ImmutableList.of(); 80 | } 81 | 82 | // Only pick the top 4 words. 83 | return index.getWords().subList(0, Math.min(4, index.getWords().size())); 84 | } 85 | 86 | public String period(Room room) { 87 | 88 | return new StringBuilder().append(timeFormat.format(room.getStartTime())) 89 | .append("-") 90 | .append(timeFormat.format(room.getEndTime())) 91 | .append(dayFormat.format(room.getStartTime())) 92 | .toString(); 93 | } 94 | 95 | public String longdate(Room room) { 96 | return new StringBuilder().append(timestampFormat.format(room.getStartTime())) 97 | .append("+12:00") 98 | .toString(); 99 | } 100 | 101 | /** 102 | * Returns a css class representing the active status of this 103 | * room based on its session time. 104 | */ 105 | @SuppressWarnings("deprecation") 106 | public String status(Room room) { 107 | Date now = new Date(); 108 | 109 | Date startTime = new Date(room.getStartTime().getTime()); 110 | startTime.setMinutes(startTime.getMinutes() - 15); 111 | 112 | Date endTime = new Date(room.getEndTime().getTime()); 113 | endTime.setMinutes(endTime.getMinutes() + 15); 114 | if (now.after(startTime) && now.before(endTime)) { 115 | return "active"; 116 | } else if (now.after(endTime)) { 117 | return "future"; 118 | } 119 | 120 | return "inactive"; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/RoomAdminPage.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web; 2 | 3 | import com.google.inject.Inject; 4 | import com.google.sitebricks.At; 5 | import com.google.sitebricks.headless.Request; 6 | import com.google.sitebricks.http.Delete; 7 | import com.google.sitebricks.http.Get; 8 | import com.google.sitebricks.http.Post; 9 | import com.wideplay.crosstalk.data.Room; 10 | import com.wideplay.crosstalk.data.store.RoomStore; 11 | import com.wideplay.crosstalk.web.auth.AdminOnly; 12 | 13 | import java.text.ParseException; 14 | import java.text.SimpleDateFormat; 15 | import java.util.Date; 16 | import java.util.List; 17 | 18 | /** 19 | * TODO secure! 20 | * 21 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 22 | */ 23 | @At("/r/room_admin") 24 | public class RoomAdminPage { 25 | @Inject 26 | private RoomStore roomStore; 27 | 28 | private List rooms; 29 | 30 | @Get @AdminOnly 31 | void displayRooms() { 32 | rooms = roomStore.list(); 33 | } 34 | 35 | @Post @AdminOnly 36 | String newRoom(Request request) throws ParseException { 37 | Room room = new Room(); 38 | room.setName(request.param("name")); 39 | room.setHost(request.param("host")); 40 | 41 | SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm"); 42 | Date startTime = dateFormat.parse(request.param("startTime")); 43 | Date endTime = dateFormat.parse(request.param("endTime")); 44 | room.setPeriod(startTime, endTime); 45 | 46 | roomStore.create(room); 47 | 48 | // redirect back here! 49 | return "/r/room_admin"; 50 | } 51 | 52 | @Delete @AdminOnly 53 | void deleteRoom(Request request) { 54 | Long roomId = Long.valueOf(request.param("id")); 55 | roomStore.remove(roomId); 56 | } 57 | 58 | public List getRooms() { 59 | return rooms; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/RoomPage.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web; 2 | 3 | import com.google.appengine.api.channel.ChannelService; 4 | import com.google.inject.Inject; 5 | import com.google.inject.name.Named; 6 | import com.google.sitebricks.At; 7 | import com.google.sitebricks.http.Get; 8 | import com.wideplay.crosstalk.CrosstalkModule; 9 | import com.wideplay.crosstalk.data.Message; 10 | import com.wideplay.crosstalk.data.Occupancy; 11 | import com.wideplay.crosstalk.data.Occupancy.TimeSegment; 12 | import com.wideplay.crosstalk.data.Room; 13 | import com.wideplay.crosstalk.data.User; 14 | import com.wideplay.crosstalk.data.store.MessageStore; 15 | import com.wideplay.crosstalk.data.store.RoomStore; 16 | import com.wideplay.crosstalk.data.store.UserStore; 17 | import com.wideplay.crosstalk.web.auth.twitter.TwitterMode; 18 | 19 | import javax.servlet.http.HttpServletResponse; 20 | import java.text.DateFormat; 21 | import java.text.SimpleDateFormat; 22 | import java.util.Collection; 23 | import java.util.Date; 24 | import java.util.List; 25 | import java.util.UUID; 26 | 27 | /** 28 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 29 | */ 30 | @At("/r/chat/:room") 31 | public class RoomPage { 32 | public static final double MAX_ACTIVITY_BUBBLES = 6.0; 33 | @Inject 34 | private ChannelService channelService; 35 | 36 | @Inject 37 | private CurrentUser currentUser; 38 | 39 | @Inject 40 | private RoomStore roomStore; 41 | 42 | @Inject 43 | private MessageStore messageStore; 44 | 45 | @Inject 46 | private UserStore userStore; 47 | 48 | @Inject @TwitterMode 49 | private boolean twitterMode; 50 | 51 | private String token; 52 | private Room room; 53 | private List messages; 54 | private int maxActivity; 55 | 56 | // Not thread safe, so need to create it each turn. 57 | private final DateFormat messageDateFormat = new SimpleDateFormat(CrosstalkModule.POST_DATE_FORMAT); 58 | 59 | @Get 60 | String displayRoom(@Named("room") String roomId, HttpServletResponse response) { 61 | if (roomId == null || roomId.isEmpty()) { 62 | return "/"; 63 | } 64 | 65 | // Find the room with this id. 66 | room = roomStore.named(roomId); 67 | 68 | // No such room! 69 | if (null == room) { 70 | return "/"; 71 | } 72 | 73 | response.setContentType("text/html; charset=utf-8"); 74 | 75 | // Create channel token specific to this user. 76 | User user = getUser(); 77 | 78 | // Create a room & user-specific unique id for this channel. 79 | String userChannelId = Long.toHexString(UUID.randomUUID().getLeastSignificantBits()); 80 | token = channelService.createChannel(userChannelId); 81 | roomStore.connectClient(user, userChannelId, room); 82 | 83 | // Set up presence for this room. 84 | Occupancy occupancy = room.getOccupancy(); 85 | occupancy.add(user); 86 | 87 | // Update occupancy. 88 | roomStore.save(occupancy); 89 | 90 | // Load the initial set of messages (currently loads everything). 91 | messages = messageStore.list(room); 92 | 93 | return null; 94 | } 95 | 96 | public String getCometToken() { 97 | return token; 98 | } 99 | 100 | public User getUser() { 101 | return currentUser.getUser(); 102 | } 103 | 104 | public Room getRoom() { 105 | return room; 106 | } 107 | 108 | public Collection getOccupants() { 109 | Collection users = userStore.resolve(room.getOccupancy().getUsers()).values(); 110 | users.remove(User.anonymous()); 111 | return users; 112 | } 113 | 114 | // For current room. 115 | public List getMessages() { 116 | return messages; 117 | } 118 | 119 | public String format(Date date) { 120 | return messageDateFormat.format(date); 121 | } 122 | 123 | public String activity(TimeSegment segment) { 124 | return renderActivity(getRoom(), segment); 125 | } 126 | 127 | public static String renderActivity(Room room, TimeSegment segment) { 128 | // Show a max of MAX_ACTIVITY_BUBBLES bubbles. 129 | double ratio = segment.getCount() / ((double) room.getOccupancy().getMaxActivity()); 130 | 131 | int count = (int)(ratio * MAX_ACTIVITY_BUBBLES); 132 | StringBuilder builder = new StringBuilder(); 133 | for (int i = 0; i < count; i++) { 134 | builder.append("
"); 135 | } 136 | return builder.toString(); 137 | } 138 | 139 | public boolean getTwitterMode() { 140 | return twitterMode; 141 | } 142 | public boolean isTwitterMode() { 143 | return twitterMode; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/RootPage.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web; 2 | 3 | import com.google.sitebricks.At; 4 | import com.google.sitebricks.headless.Reply; 5 | import com.google.sitebricks.headless.Service; 6 | import com.google.sitebricks.http.Get; 7 | 8 | /** 9 | * This page exists only to redirect to /home for security. 10 | * 11 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 12 | */ 13 | @At("/") @Service 14 | public class RootPage { 15 | @Get 16 | Reply redirect() { 17 | return Reply.saying().redirect("/r/home"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/SitebricksConfig.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web; 2 | 3 | import com.google.inject.AbstractModule; 4 | import com.google.inject.Guice; 5 | import com.google.inject.Injector; 6 | import com.google.inject.servlet.GuiceServletContextListener; 7 | import com.wideplay.crosstalk.CrosstalkModule; 8 | import com.wideplay.crosstalk.web.auth.buzz.BuzzAuthModule; 9 | import com.wideplay.crosstalk.web.auth.twitter.TwitterAuthModule; 10 | 11 | /** 12 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 13 | */ 14 | public class SitebricksConfig extends GuiceServletContextListener { 15 | @Override 16 | protected Injector getInjector() { 17 | return Guice.createInjector(new AbstractModule() { 18 | @Override 19 | protected void configure() { 20 | String twittermode = System.getProperty("twittermode"); 21 | if (null != twittermode && Boolean.valueOf(twittermode)) { 22 | install(new TwitterAuthModule()); 23 | } else { 24 | install(new BuzzAuthModule()); 25 | } 26 | 27 | install(new CrosstalkModule()); 28 | } 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/UploadService.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web; 2 | 3 | import com.google.appengine.api.datastore.Blob; 4 | import com.google.inject.Inject; 5 | import com.google.sitebricks.At; 6 | import com.google.sitebricks.headless.Reply; 7 | import com.google.sitebricks.headless.Service; 8 | import com.google.sitebricks.http.Post; 9 | import com.wideplay.crosstalk.data.Attachment; 10 | import com.wideplay.crosstalk.data.store.MessageStore; 11 | import org.apache.commons.fileupload.FileUploadException; 12 | import org.apache.commons.io.IOUtils; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | 16 | import javax.servlet.ServletInputStream; 17 | import javax.servlet.http.HttpServletRequest; 18 | import java.io.IOException; 19 | import java.util.UUID; 20 | 21 | /** 22 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 23 | */ 24 | @At("/r/upload") @Service 25 | public class UploadService { 26 | private static final Logger log = LoggerFactory.getLogger(UploadService.class); 27 | 28 | @Inject 29 | private CurrentUser currentUser; 30 | 31 | @Inject 32 | private MessageStore messageStore; 33 | 34 | @Post 35 | Reply receiveFile(HttpServletRequest request) throws IOException, FileUploadException { 36 | String fileName = request.getParameter("qqfile"); 37 | log.info("Received upload of file named '{}'", fileName); 38 | 39 | // Get the image representation 40 | // ServletFileUpload upload = new ServletFileUpload(); 41 | // FileItemIterator iter = upload.getItemIterator(request); 42 | // FileItemStream file = iter.next(); 43 | // InputStream fileStream = file.openStream(); 44 | 45 | // Do something with this stream. 46 | ServletInputStream inputStream = request.getInputStream(); 47 | byte[] data = IOUtils.toByteArray(inputStream); 48 | IOUtils.closeQuietly(inputStream); 49 | 50 | Attachment attachment = new Attachment(); 51 | attachment.setId(UUID.randomUUID().getMostSignificantBits()); 52 | attachment.setAuthor(currentUser.getUser()); 53 | attachment.setName(fileName); 54 | attachment.setContent(new Blob(data)); 55 | attachment.setMimeType(determineMimeType(fileName)); // determine from file name =( 56 | 57 | messageStore.save(attachment); 58 | 59 | log.info("Saving attachment as id [{}]", attachment.getId()); 60 | 61 | // Send id back to current user as confirmation. 62 | return Reply.with("{ success: 'true', id: '" + attachment.getId() + "' }"); 63 | } 64 | 65 | private String determineMimeType(String fileName) { 66 | if (fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")) { 67 | return "image/jpeg"; 68 | } else if (fileName.endsWith(".gif")) { 69 | return "image/gif"; 70 | } else if (fileName.endsWith(".png")) { 71 | return "image/png"; 72 | } 73 | // Unknown. 74 | return "application/octet-stream"; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/WebModule.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web; 2 | 3 | import com.google.inject.AbstractModule; 4 | import com.google.inject.servlet.RequestScoped; 5 | import com.wideplay.crosstalk.web.auth.twitter.Twitter; 6 | 7 | /** 8 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 9 | */ 10 | public class WebModule extends AbstractModule { 11 | @Override 12 | protected void configure() { 13 | bind(CurrentUser.class).in(RequestScoped.class); 14 | bind(Twitter.class); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/AdminMethodInterceptor.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth; 2 | 3 | import com.google.common.collect.ImmutableSet; 4 | import com.google.inject.Inject; 5 | import com.google.inject.Provider; 6 | import com.wideplay.crosstalk.web.CurrentUser; 7 | import org.aopalliance.intercept.MethodInterceptor; 8 | import org.aopalliance.intercept.MethodInvocation; 9 | 10 | /** 11 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 12 | */ 13 | class AdminMethodInterceptor implements MethodInterceptor { 14 | private static final ImmutableSet ADMINS = ImmutableSet.of("themaninblue", "dhanji", 15 | "crosstalkme"); 16 | @Inject 17 | private Provider currentUser; 18 | 19 | @Override 20 | public Object invoke(MethodInvocation invocation) throws Throwable { 21 | CurrentUser user = currentUser.get(); 22 | if (user.isAnonymous() || !ADMINS.contains(user.getUser().getUsername())) { 23 | throw new IllegalAccessException("Only admin users may access this function"); 24 | } 25 | 26 | return invocation.proceed(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/AdminOnly.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | /** 9 | * Used to prevent users from calling certain methods. 10 | * 11 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 12 | */ 13 | @Retention(RetentionPolicy.RUNTIME) 14 | @Target(ElementType.METHOD) 15 | public @interface AdminOnly { 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/AuthModule.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth; 2 | 3 | import com.google.inject.AbstractModule; 4 | import com.google.inject.matcher.Matchers; 5 | import org.aopalliance.intercept.MethodInterceptor; 6 | 7 | import static com.google.inject.matcher.Matchers.any; 8 | 9 | /** 10 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 11 | */ 12 | public class AuthModule extends AbstractModule { 13 | @Override 14 | protected void configure() { 15 | MethodInterceptor interceptor = new SecureMethodInterceptor(); 16 | requestInjection(interceptor); 17 | bindInterceptor(any(), Matchers.annotatedWith(Secure.class), interceptor); 18 | 19 | interceptor = new AdminMethodInterceptor(); 20 | requestInjection(interceptor); 21 | bindInterceptor(any(), Matchers.annotatedWith(AdminOnly.class), interceptor); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/Login.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth; 2 | 3 | import com.google.common.collect.ImmutableMap; 4 | import com.google.gson.Gson; 5 | import com.google.inject.Inject; 6 | import com.google.inject.Provider; 7 | import com.google.sitebricks.At; 8 | import com.google.sitebricks.headless.Reply; 9 | import com.google.sitebricks.headless.Request; 10 | import com.google.sitebricks.headless.Service; 11 | import com.google.sitebricks.http.Get; 12 | import com.wideplay.crosstalk.data.Room; 13 | import com.wideplay.crosstalk.data.store.RoomStore; 14 | import com.wideplay.crosstalk.data.store.UserStore; 15 | import com.wideplay.crosstalk.web.Broadcaster; 16 | import com.wideplay.crosstalk.web.CurrentUser; 17 | import com.wideplay.crosstalk.web.auth.buzz.BuzzApi; 18 | import com.wideplay.crosstalk.web.auth.twitter.Twitter; 19 | import com.wideplay.crosstalk.web.auth.twitter.Twitter.OAuthRedirect; 20 | import com.wideplay.crosstalk.web.auth.twitter.TwitterMode; 21 | 22 | /** 23 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 24 | */ 25 | @At("/login") @Service 26 | public class Login { 27 | @Inject 28 | private Provider currentUser; 29 | 30 | @Inject 31 | private UserStore userStore; 32 | 33 | @Inject 34 | private RoomStore roomStore; 35 | 36 | @Inject 37 | private Broadcaster broadcaster; 38 | 39 | @Inject 40 | private Twitter twitter; 41 | 42 | @Inject 43 | private BuzzApi buzz; 44 | 45 | @Get 46 | Reply get(@TwitterMode boolean twitterMode, Request request, Gson gson) { 47 | // Decrement lurker count. 48 | String roomId = request.param("r"); 49 | String lastUrl = request.param("u"); 50 | CurrentUser user = currentUser.get(); 51 | if (null != roomId && user.isAnonymous()) { 52 | Room room = roomStore.byId(Long.valueOf(roomId)); 53 | if (null != room) { 54 | // Broadcast that one anonymous user has left... 55 | broadcaster.broadcast(room, null, gson.toJson(ImmutableMap.of( 56 | "rpc", "leave", 57 | "leaver", user.getUser() 58 | ))); 59 | } 60 | } 61 | 62 | String redirectUrl; 63 | if (twitterMode) { 64 | OAuthRedirect redirect = twitter.redirectForAuth(); 65 | redirectUrl = redirect.getUrl(); 66 | 67 | // We need to save these temporary credentials to complete the OAuth dance. 68 | userStore.newOAuthToken(redirect.getRequestToken(), redirect.getTokenSecret(), lastUrl); 69 | } else { 70 | OAuthRedirect redirect = buzz.redirectForAuth(); 71 | redirectUrl = redirect.getUrl(); 72 | 73 | // We need to save these temporary credentials to complete the OAuth dance. 74 | userStore.newOAuthToken(redirect.getRequestToken(), redirect.getTokenSecret(), lastUrl); 75 | } 76 | 77 | return Reply.saying().redirect(redirectUrl); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/Logout.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth; 2 | 3 | import com.google.common.collect.ImmutableMap; 4 | import com.google.gson.Gson; 5 | import com.google.inject.Inject; 6 | import com.google.inject.Provider; 7 | import com.google.inject.Singleton; 8 | import com.google.sitebricks.At; 9 | import com.google.sitebricks.headless.Reply; 10 | import com.google.sitebricks.headless.Service; 11 | import com.google.sitebricks.http.Get; 12 | import com.wideplay.crosstalk.data.Room; 13 | import com.wideplay.crosstalk.data.User; 14 | import com.wideplay.crosstalk.data.store.RoomStore; 15 | import com.wideplay.crosstalk.data.store.UserStore; 16 | import com.wideplay.crosstalk.web.Broadcaster; 17 | import com.wideplay.crosstalk.web.CurrentUser; 18 | 19 | import javax.servlet.http.Cookie; 20 | import javax.servlet.http.HttpServletResponse; 21 | import java.util.Collection; 22 | 23 | /** 24 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 25 | */ 26 | @At("/logout") @Service @Singleton 27 | public class Logout { 28 | @Inject 29 | private Provider currentUserProvider; 30 | 31 | @Inject 32 | private Provider userStore; 33 | 34 | @Inject 35 | private Provider roomStore; 36 | 37 | @Inject 38 | private Broadcaster broadcaster; 39 | 40 | @Get 41 | Reply logout(HttpServletResponse response, Gson gson) { 42 | CurrentUser currentUser = currentUserProvider.get(); 43 | 44 | // Unset session cookie by resetting its max age. 45 | Cookie cookie = currentUser.getSessionCookie(); 46 | if (null != cookie) { 47 | cookie.setMaxAge(0); 48 | response.addCookie(cookie); 49 | 50 | // Kick user out of all rooms. 51 | User leaver = currentUser.getUser(); 52 | Collection rooms = roomStore.get().roomsOf(leaver); 53 | 54 | for (Room room : rooms) { 55 | broadcaster.broadcast(room, leaver, gson.toJson(ImmutableMap.of( 56 | "rpc", "leave", 57 | "leaver", leaver 58 | ))); 59 | } 60 | 61 | // Remove user from session store too. 62 | userStore.get().logout(cookie.getValue()); 63 | } 64 | 65 | return Reply.saying().redirect("/"); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/Secure.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | /** 9 | * Used to prevent anonymous users from calling certain methods. 10 | * 11 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 12 | */ 13 | @Retention(RetentionPolicy.RUNTIME) 14 | @Target(ElementType.METHOD) 15 | public @interface Secure { 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/SecureMethodInterceptor.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth; 2 | 3 | import com.google.inject.Inject; 4 | import com.google.inject.Provider; 5 | import com.wideplay.crosstalk.web.CurrentUser; 6 | import org.aopalliance.intercept.MethodInterceptor; 7 | import org.aopalliance.intercept.MethodInvocation; 8 | 9 | /** 10 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 11 | */ 12 | class SecureMethodInterceptor implements MethodInterceptor { 13 | @Inject 14 | private Provider currentUser; 15 | @Override 16 | public Object invoke(MethodInvocation invocation) throws Throwable { 17 | if (currentUser.get().isAnonymous()) { 18 | throw new IllegalAccessException("Anonymous users may not access this function"); 19 | } 20 | 21 | return invocation.proceed(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/buzz/BuzzApi.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth.buzz; 2 | 3 | import com.google.api.client.auth.oauth.OAuthAuthorizeTemporaryTokenUrl; 4 | import com.google.api.client.auth.oauth.OAuthCredentialsResponse; 5 | import com.google.api.client.auth.oauth.OAuthHmacSigner; 6 | import com.google.api.client.auth.oauth.OAuthParameters; 7 | import com.google.api.client.googleapis.GoogleHeaders; 8 | import com.google.api.client.googleapis.auth.oauth.GoogleOAuthGetAccessToken; 9 | import com.google.api.client.googleapis.auth.oauth.GoogleOAuthGetTemporaryToken; 10 | import com.google.api.client.http.HttpTransport; 11 | import com.google.gson.Gson; 12 | import com.google.inject.Inject; 13 | import com.google.inject.servlet.RequestScoped; 14 | import com.wideplay.crosstalk.data.LoginToken; 15 | import com.wideplay.crosstalk.data.User; 16 | import com.wideplay.crosstalk.data.store.UserStore; 17 | import com.wideplay.crosstalk.web.CurrentUser; 18 | import com.wideplay.crosstalk.web.auth.twitter.Twitter; 19 | import oauth.signpost.OAuthConsumer; 20 | import oauth.signpost.OAuthProvider; 21 | import oauth.signpost.basic.DefaultOAuthConsumer; 22 | import oauth.signpost.basic.DefaultOAuthProvider; 23 | import oauth.signpost.exception.OAuthCommunicationException; 24 | import oauth.signpost.exception.OAuthExpectationFailedException; 25 | import oauth.signpost.exception.OAuthMessageSignerException; 26 | import org.apache.commons.io.IOUtils; 27 | import org.slf4j.Logger; 28 | import org.slf4j.LoggerFactory; 29 | 30 | import java.io.IOException; 31 | import java.io.InputStream; 32 | import java.net.HttpURLConnection; 33 | import java.net.MalformedURLException; 34 | import java.net.URL; 35 | import java.net.URLEncoder; 36 | 37 | /** 38 | * Encapsulates the twitter-specific Oauth stuff. 39 | * 40 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 41 | */ 42 | @RequestScoped 43 | public class BuzzApi { 44 | private static final Logger log = LoggerFactory.getLogger(BuzzApi.class); 45 | public static final String APP_DOMAIN = System.getProperty("crosstalk.domain"); 46 | public static final String CONSUMER_KEY = "crosstalkchat.appspot.com"; 47 | public static final String CONSUMER_SECRET = "Z3U9dZKymbTXqAaO6dwM3sV3"; 48 | // public static final String CONSUMER_KEY = "anonymous"; 49 | // public static final String CONSUMER_SECRET = "anonymous"; 50 | 51 | @SuppressWarnings("deprecation") 52 | public static final String BUZZ_SCOPE_READONLY = 53 | URLEncoder.encode("https://www.googleapis.com/auth/buzz"); 54 | 55 | private final OAuthConsumer consumer = new DefaultOAuthConsumer(CONSUMER_KEY, CONSUMER_SECRET); 56 | private final OAuthProvider provider = new DefaultOAuthProvider( 57 | "https://www.google.com/accounts/OAuthGetRequestToken?scope=" + BUZZ_SCOPE_READONLY 58 | + "&next=" + URLEncoder.encode(APP_DOMAIN + "/oauth/buzz"), 59 | "https://www.google.com/accounts/OAuthGetAccessToken", 60 | "https://www.google.com/buzz/api/auth/OAuthAuthorizeToken?scope=" 61 | + BUZZ_SCOPE_READONLY 62 | + "&domain=" + CONSUMER_KEY); 63 | 64 | @Inject 65 | private CurrentUser currentUser; 66 | 67 | @Inject 68 | private Gson gson; 69 | 70 | @Inject 71 | private UserStore userStore; 72 | 73 | 74 | 75 | public Twitter.OAuthRedirect redirectForAuth() { 76 | try { 77 | 78 | OAuthHmacSigner signer = new OAuthHmacSigner(); 79 | GoogleOAuthGetTemporaryToken temporaryToken = new GoogleOAuthGetTemporaryToken(); 80 | // temporaryToken.transport = Util.AUTH_TRANSPORT; 81 | signer.clientSharedSecret = CONSUMER_SECRET; 82 | temporaryToken.signer = signer; 83 | temporaryToken.consumerKey = CONSUMER_KEY; 84 | temporaryToken.scope = "https://www.googleapis.com/auth/buzz"; 85 | temporaryToken.displayName = "Crosstalk"; 86 | temporaryToken.callback = APP_DOMAIN + "/oauth/buzz"; 87 | OAuthCredentialsResponse tempCredentials = temporaryToken.execute(); 88 | signer.tokenSharedSecret = tempCredentials.tokenSecret; 89 | 90 | // authorization URL 91 | OAuthAuthorizeTemporaryTokenUrl authorizeUrl = new OAuthAuthorizeTemporaryTokenUrl( 92 | "https://www.google.com/buzz/api/auth/OAuthAuthorizeToken"); 93 | authorizeUrl.set("scope", temporaryToken.scope); 94 | authorizeUrl.set("domain", CONSUMER_KEY); 95 | authorizeUrl.set("xoauth_displayname", "Crosstalk"); 96 | authorizeUrl.temporaryToken = tempCredentials.token; 97 | String url = authorizeUrl.build(); 98 | 99 | System.out.println("REQUEST TOKEN REDIRECT URL: " + url); 100 | return new Twitter.OAuthRedirect(url, 101 | tempCredentials.token, tempCredentials.tokenSecret); 102 | 103 | } catch (IOException e) { 104 | log.error("Error redirecting for OAuth.", e); 105 | } 106 | return null; 107 | } 108 | 109 | // public Twitter.OAuthRedirect redirectForAuth() { 110 | // try { 111 | //// Buzz buzz = new Buzz(); 112 | //// String url = buzz.getAuthenticationUrl(Buzz.BUZZ_SCOPE_READONLY, CONSUMER_KEY, CONSUMER_SECRET, 113 | //// "http://crosstalk.appspot.com/oauth/buzz"); 114 | // // HACK as last parameter in query string is oauth_callback. 115 | // provider.setOAuth10a(true); 116 | // String url = provider.retrieveRequestToken(consumer, null); 117 | // System.out.println("REQUEST TOKEN REDIRECT URL: " + url); 118 | // return new Twitter.OAuthRedirect(url, 119 | // consumer.getToken(), consumer.getTokenSecret()); 120 | // } catch (OAuthMessageSignerException e) { 121 | // log.error("Oauth failed", e); 122 | // } catch (OAuthNotAuthorizedException e) { 123 | // log.error("Oauth failed", e); 124 | // } catch (OAuthExpectationFailedException e) { 125 | // log.error("Oauth failed", e); 126 | // } catch (OAuthCommunicationException e) { 127 | // log.error("Oauth failed", e); 128 | // } 129 | //// catch (BuzzAuthenticationException e) { 130 | //// e.printStackTrace(); //To change body of catch statement use File | Settings | File Templates. 131 | //// } 132 | // return null; 133 | // } 134 | 135 | public LoginToken authorize(String token, String verification) { 136 | OAuthHmacSigner signer = new OAuthHmacSigner(); 137 | try { 138 | LoginToken loginToken = userStore.claimOAuthToken(token); 139 | // access token 140 | 141 | OAuthCredentialsResponse credentials; 142 | GoogleOAuthGetAccessToken accessToken = new GoogleOAuthGetAccessToken(); 143 | // accessToken.transport = Util.AUTH_TRANSPORT; 144 | signer.clientSharedSecret = CONSUMER_SECRET; 145 | accessToken.temporaryToken = token; 146 | accessToken.signer = signer; 147 | accessToken.consumerKey = CONSUMER_KEY; 148 | accessToken.verifier = verification; 149 | signer.tokenSharedSecret = loginToken.getTokenSecret(); 150 | // signer.tokenSharedSecret = credentials.tokenSecret; 151 | credentials = accessToken.execute(); 152 | 153 | OAuthParameters authorizer = new OAuthParameters(); 154 | authorizer.consumerKey = CONSUMER_KEY; 155 | authorizer.signer = signer; 156 | authorizer.token = credentials.token; 157 | authorizer.signRequestsUsingAuthorizationHeader(newTransport()); 158 | 159 | String secret = loginToken.getTokenSecret(); 160 | if (null == secret) { 161 | throw new IllegalStateException("Unknown oauth request token " + token); 162 | } 163 | 164 | // Now that we have the proper access token, set that on the current user. 165 | // (Will be saved later). 166 | User user = currentUser.getUser(); 167 | user.setTwitterAccessToken(credentials.token); 168 | user.setTwitterTokenSecret(credentials.tokenSecret); 169 | 170 | return loginToken; 171 | } catch (IOException e) { 172 | log.error("authorize", e); 173 | } 174 | return null; 175 | } 176 | 177 | static HttpTransport newTransport() { 178 | HttpTransport result = new HttpTransport(); 179 | // GoogleUtils.useMethodOverride(result); 180 | GoogleHeaders headers = new GoogleHeaders(); 181 | headers.setApplicationName("Google-BuzzSample/1.0"); 182 | result.defaultHeaders = headers; 183 | return result; 184 | } 185 | // public LoginToken authorize(String token, String verification) { 186 | // try { 187 | // LoginToken loginToken = userStore.claimOAuthToken(token); 188 | // String secret = loginToken.getTokenSecret(); 189 | // if (null == secret) { 190 | // throw new IllegalStateException("Unknown oauth request token " + token); 191 | // } 192 | // 193 | // // "Resume" the oauth dance with the appropriate token and temporary secret. 194 | // consumer.setTokenWithSecret(token, secret); 195 | // provider.retrieveAccessToken(consumer, verification); 196 | // 197 | // // Now that we have the proper access token, set that on the current user. 198 | // // (Will be saved later). 199 | // User user = currentUser.getUser(); 200 | // user.setTwitterAccessToken(consumer.getToken()); 201 | // user.setTwitterTokenSecret(consumer.getTokenSecret()); 202 | // 203 | // return loginToken; 204 | // } catch (OAuthMessageSignerException e) { 205 | // log.error("Oauth failed", e); 206 | // } catch (OAuthNotAuthorizedException e) { 207 | // log.error("Oauth failed", e); 208 | // } catch (OAuthExpectationFailedException e) { 209 | // log.error("Oauth failed", e); 210 | // } catch (OAuthCommunicationException e) { 211 | // log.error("Oauth failed", e); 212 | // } 213 | // return null; 214 | // } 215 | 216 | /** 217 | * Makes a signed OAuth call to twitter at this URL, authed as the current user. 218 | */ 219 | public String call(String urlAsString) { 220 | User user = currentUser.getUser(); 221 | 222 | return call(user, urlAsString); 223 | } 224 | 225 | public String call(User user, String urlAsString) { 226 | consumer.setTokenWithSecret(user.getTwitterAccessToken(), user.getTwitterTokenSecret()); 227 | 228 | // create an HTTP request to a protected resource 229 | URL url; 230 | try { 231 | url = new URL(urlAsString); 232 | HttpURLConnection connection = (HttpURLConnection) url.openConnection(); 233 | connection.setRequestProperty("Content-Type", "application/json"); 234 | 235 | // sign the request 236 | consumer.sign(connection); 237 | 238 | // send the request 239 | connection.connect(); 240 | 241 | InputStream inputStream = connection.getInputStream(); 242 | if (connection.getResponseCode() == 200) { 243 | return IOUtils.toString(inputStream, "UTF-8"); 244 | } else { 245 | log.error("Twitter returned error code {} with message {}", connection.getResponseCode(), 246 | IOUtils.toString(inputStream)); 247 | } 248 | IOUtils.closeQuietly(inputStream); 249 | 250 | } catch (MalformedURLException e) { 251 | log.error("Could not perform Twitter OAuth request", e); 252 | } catch (OAuthExpectationFailedException e) { 253 | log.error("Could not perform Twitter OAuth request", e); 254 | } catch (OAuthCommunicationException e) { 255 | log.error("Could not perform Twitter OAuth request", e); 256 | } catch (OAuthMessageSignerException e) { 257 | log.error("Could not perform Twitter OAuth request", e); 258 | } catch (IOException e) { 259 | log.error("Could not perform Twitter OAuth request", e); 260 | } 261 | 262 | return null; 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/buzz/BuzzAuthFilter.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth.buzz; 2 | 3 | import com.google.inject.Inject; 4 | import com.google.inject.Provider; 5 | import com.google.inject.Singleton; 6 | import com.wideplay.crosstalk.data.User; 7 | import com.wideplay.crosstalk.data.store.UserStore; 8 | import com.wideplay.crosstalk.web.CurrentUser; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | import javax.servlet.Filter; 13 | import javax.servlet.FilterChain; 14 | import javax.servlet.FilterConfig; 15 | import javax.servlet.ServletException; 16 | import javax.servlet.ServletRequest; 17 | import javax.servlet.ServletResponse; 18 | import javax.servlet.http.Cookie; 19 | import javax.servlet.http.HttpServletRequest; 20 | import java.io.IOException; 21 | 22 | /** 23 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 24 | */ 25 | @Singleton 26 | public class BuzzAuthFilter implements Filter { 27 | private static final Logger log = LoggerFactory.getLogger(BuzzAuthFilter.class); 28 | 29 | @Inject 30 | private Provider userStoreProvider; 31 | 32 | @Inject 33 | private Provider currentUserProvider; 34 | 35 | public void init(FilterConfig filterConfig) throws ServletException { 36 | } 37 | 38 | public void doFilter(ServletRequest servletRequest, 39 | ServletResponse servletResponse, 40 | FilterChain filterChain) throws IOException, ServletException { 41 | HttpServletRequest request = (HttpServletRequest) servletRequest; 42 | UserStore userStore = userStoreProvider.get(); 43 | CurrentUser currentUser = currentUserProvider.get(); 44 | 45 | // First see if there is a session cookie. 46 | Cookie sessionCookie = currentUser.getSessionCookie(); 47 | if (null == sessionCookie) { 48 | // Auth as anonymous. 49 | currentUser.setUser(User.anonymous()); 50 | } else { 51 | // Find the user associated with this session cookie and log her in. 52 | User loggedIn = userStore.isLoggedIn(sessionCookie.getValue()); 53 | 54 | // No such user was found. (Invalid session cookie, continue as anonymous) 55 | if (null == loggedIn) { 56 | loggedIn = User.anonymous(); 57 | } 58 | 59 | currentUser.setUser(loggedIn); 60 | } 61 | 62 | filterChain.doFilter(servletRequest, servletResponse); 63 | } 64 | 65 | public void destroy() { 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/buzz/BuzzAuthModule.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth.buzz; 2 | 3 | import com.google.inject.AbstractModule; 4 | import com.google.inject.servlet.ServletModule; 5 | import com.wideplay.crosstalk.web.auth.twitter.TwitterMode; 6 | 7 | /** 8 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 9 | */ 10 | public class BuzzAuthModule extends AbstractModule { 11 | @Override 12 | protected void configure() { 13 | install(new ServletModule() { 14 | 15 | @Override 16 | protected void configureServlets() { 17 | filter("/r/*").through(BuzzAuthFilter.class); 18 | filter("/logout").through(BuzzAuthFilter.class); 19 | filter("/oauth/buzz").through(BuzzAuthFilter.class); // HACK! 20 | 21 | // Secures site only to google.com users by preventing access unless logged in. 22 | if (Boolean.valueOf(System.getProperty("supersecure"))) { 23 | filter("/r/*").through(GoogleComSecureAuthFilter.class); 24 | filter("/").through(GoogleComSecureAuthFilter.class); 25 | filter("/r/room_admin").through(GoogleComSecureAuthFilter.class); 26 | } 27 | } 28 | 29 | }); 30 | bindConstant().annotatedWith(TwitterMode.class).to(false); 31 | bind(BuzzApi.class); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/buzz/BuzzOAuthCallback.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth.buzz; 2 | 3 | import com.google.gson.Gson; 4 | import com.google.inject.Inject; 5 | import com.google.inject.Provider; 6 | import com.google.sitebricks.At; 7 | import com.google.sitebricks.headless.Reply; 8 | import com.google.sitebricks.headless.Request; 9 | import com.google.sitebricks.headless.Service; 10 | import com.google.sitebricks.http.Get; 11 | import com.wideplay.crosstalk.data.LoginToken; 12 | import com.wideplay.crosstalk.data.User; 13 | import com.wideplay.crosstalk.data.buzz.BuzzSearch; 14 | import com.wideplay.crosstalk.data.store.UserStore; 15 | import com.wideplay.crosstalk.web.CurrentUser; 16 | import org.slf4j.Logger; 17 | import org.slf4j.LoggerFactory; 18 | 19 | import javax.servlet.http.Cookie; 20 | import javax.servlet.http.HttpServletResponse; 21 | 22 | /** 23 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 24 | */ 25 | @At("/oauth/buzz") @Service 26 | public class BuzzOAuthCallback { 27 | private static final Logger log = LoggerFactory.getLogger(BuzzOAuthCallback.class); 28 | 29 | @Inject 30 | private Provider currentUser; 31 | 32 | @Inject 33 | private Provider userStore; 34 | 35 | @Inject 36 | private Gson gson; 37 | 38 | @Inject 39 | private BuzzApi buzz; 40 | 41 | @Get 42 | Reply callback(Request request, HttpServletResponse response) { 43 | String token = request.param("oauth_token"); 44 | String verifier = request.param("oauth_verifier"); 45 | 46 | String redirect = "/r/chat/1"; 47 | log.debug("Twitter callback successful with verifier {} ", verifier); 48 | if (verifier != null) { 49 | LoginToken loginToken = buzz.authorize(token, verifier); 50 | if (loginToken.getLastUrl() != null) { 51 | redirect = loginToken.getLastUrl(); 52 | } 53 | } 54 | 55 | // And now we should log this user in properly. 56 | CurrentUser thisUser = currentUser.get(); 57 | 58 | // Set session cookie. 59 | String sessionId = thisUser.newSessionId(); 60 | Cookie cookie = new Cookie(CurrentUser.SESSION_COOKIE_NAME, sessionId); 61 | cookie.setPath("/"); 62 | cookie.setMaxAge(60 * 60 * 24 /* 1 day */); 63 | response.addCookie(cookie); 64 | 65 | // We first need to some how get the username out of this so we can identify who it is! 66 | String creds = buzz.call("https://www.googleapis.com/buzz/v1/activities/@me/@self?alt=json"); 67 | 68 | // Log user in, in our own user store. 69 | BuzzSearch data = gson.fromJson(creds, BuzzSearch.Data.class).getData(); 70 | 71 | User user = new User(); 72 | if (data.getItems().isEmpty()) { 73 | return Reply.with("Error: You have never posted anything in Buzz so we can't log you in!"); 74 | // Maybe check something else? 75 | // creds = buzz.call("https://www.googleapis.com/buzz/v1/people/@me/@self?alt=json"); 76 | // BuzzUser userData = gson.fromJson(creds, BuzzUser.Data.class).getUser(); 77 | // user.setUsername(userData.getDisplayName()); 78 | // user.setDisplayName(userData.getDisplayName()); 79 | // user.setAvatar(userData.getAvatar()); 80 | } else { 81 | BuzzSearch.Buzz buzz = data.getItems().get(0); 82 | if (!buzz.getArbitraryPermalink().startsWith("http://www.google.com/buzz/a/google.com/")) { 83 | // Dont allow non-google domains. 84 | return Reply.saying().forbidden(); 85 | } 86 | user.setUsername(buzz.getActor().getName()); 87 | user.setDisplayName(buzz.getActor().getName()); 88 | user.setAvatar(buzz.getActor().getAvatar()); 89 | } 90 | 91 | user.setTwitterAccessToken(thisUser.getUser().getTwitterAccessToken()); 92 | user.setTwitterTokenSecret(thisUser.getUser().getTwitterTokenSecret()); 93 | 94 | userStore.get().loginAndMaybeCreate(sessionId, user); 95 | 96 | return Reply.saying().redirect(redirect) ; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/buzz/GoogleComSecureAuthFilter.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth.buzz; 2 | 3 | import com.google.inject.Inject; 4 | import com.google.inject.Provider; 5 | import com.google.inject.Singleton; 6 | import com.wideplay.crosstalk.web.CurrentUser; 7 | 8 | import javax.servlet.Filter; 9 | import javax.servlet.FilterChain; 10 | import javax.servlet.FilterConfig; 11 | import javax.servlet.ServletException; 12 | import javax.servlet.ServletRequest; 13 | import javax.servlet.ServletResponse; 14 | import javax.servlet.http.HttpServletResponse; 15 | import java.io.IOException; 16 | 17 | /** 18 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 19 | */ 20 | @Singleton 21 | public class GoogleComSecureAuthFilter implements Filter { 22 | @Inject 23 | private Provider currentUser; 24 | 25 | @Override 26 | public void init(FilterConfig filterConfig) throws ServletException { 27 | } 28 | 29 | @Override 30 | public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 31 | throws IOException, ServletException { 32 | // Don't allow the anonymous user to get in to this site. 33 | if (currentUser.get().isAnonymous()) { 34 | ((HttpServletResponse)response).sendRedirect("/login"); 35 | } 36 | chain.doFilter(request, response); 37 | } 38 | 39 | @Override 40 | public void destroy() { 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/twitter/Twitter.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth.twitter; 2 | 3 | import com.google.gson.Gson; 4 | import com.google.inject.Inject; 5 | import com.google.inject.servlet.RequestScoped; 6 | import com.wideplay.crosstalk.data.LoginToken; 7 | import com.wideplay.crosstalk.data.User; 8 | import com.wideplay.crosstalk.data.store.UserStore; 9 | import com.wideplay.crosstalk.web.CurrentUser; 10 | import oauth.signpost.OAuthConsumer; 11 | import oauth.signpost.OAuthProvider; 12 | import oauth.signpost.basic.DefaultOAuthConsumer; 13 | import oauth.signpost.basic.DefaultOAuthProvider; 14 | import oauth.signpost.exception.OAuthCommunicationException; 15 | import oauth.signpost.exception.OAuthExpectationFailedException; 16 | import oauth.signpost.exception.OAuthMessageSignerException; 17 | import oauth.signpost.exception.OAuthNotAuthorizedException; 18 | import org.apache.commons.io.IOUtils; 19 | import org.slf4j.Logger; 20 | import org.slf4j.LoggerFactory; 21 | 22 | import java.io.IOException; 23 | import java.io.InputStream; 24 | import java.net.HttpURLConnection; 25 | import java.net.MalformedURLException; 26 | import java.net.URL; 27 | 28 | /** 29 | * Encapsulates the twitter-specific Oauth stuff. 30 | * 31 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 32 | */ 33 | @RequestScoped 34 | public class Twitter { 35 | private static final Logger log = LoggerFactory.getLogger(Twitter.class); 36 | public static final String CONSUMER_KEY = "BaBzQIsMvsuEF4e3xpmxQ"; 37 | public static final String CONSUMER_SECRET = "NvNM3KUmfSkWhWxqZzEJmREorVKmd54G7C9jDv1zFw"; 38 | 39 | private final OAuthConsumer consumer = new DefaultOAuthConsumer(CONSUMER_KEY, CONSUMER_SECRET); 40 | private final OAuthProvider provider = new DefaultOAuthProvider( 41 | "http://twitter.com/oauth/request_token", 42 | "http://twitter.com/oauth/access_token", 43 | "http://twitter.com/oauth/authorize"); 44 | 45 | @Inject 46 | private CurrentUser currentUser; 47 | 48 | @Inject 49 | private Gson gson; 50 | 51 | @Inject 52 | private UserStore userStore; 53 | 54 | public static class OAuthRedirect { 55 | private final String url; 56 | private final String requestToken; 57 | private final String tokenSecret; 58 | 59 | public OAuthRedirect(String url, String requestToken, String tokenSecret) { 60 | this.url = url; 61 | this.requestToken = requestToken; 62 | this.tokenSecret = tokenSecret; 63 | } 64 | 65 | public String getUrl() { 66 | return url; 67 | } 68 | 69 | public String getRequestToken() { 70 | return requestToken; 71 | } 72 | 73 | public String getTokenSecret() { 74 | return tokenSecret; 75 | } 76 | } 77 | 78 | public OAuthRedirect redirectForAuth() { 79 | try { 80 | return new OAuthRedirect(provider.retrieveRequestToken(consumer, null), 81 | consumer.getToken(), consumer.getTokenSecret()); 82 | } catch (OAuthMessageSignerException e) { 83 | log.error("Oauth failed", e); 84 | } catch (OAuthNotAuthorizedException e) { 85 | log.error("Oauth failed", e); 86 | } catch (OAuthExpectationFailedException e) { 87 | log.error("Oauth failed", e); 88 | } catch (OAuthCommunicationException e) { 89 | log.error("Oauth failed", e); 90 | } 91 | return null; 92 | } 93 | 94 | public LoginToken authorize(String token, String verification) { 95 | try { 96 | LoginToken loginToken = userStore.claimOAuthToken(token); 97 | String secret = loginToken.getTokenSecret(); 98 | if (null == secret) { 99 | throw new IllegalStateException("Unknown oauth request token " + token); 100 | } 101 | 102 | // "Resume" the oauth dance with the appropriate token and temporary secret. 103 | consumer.setTokenWithSecret(token, secret); 104 | provider.retrieveAccessToken(consumer, verification); 105 | 106 | // Now that we have the proper access token, set that on the current user. 107 | // (Will be saved later). 108 | User user = currentUser.getUser(); 109 | user.setTwitterAccessToken(consumer.getToken()); 110 | user.setTwitterTokenSecret(consumer.getTokenSecret()); 111 | 112 | return loginToken; 113 | } catch (OAuthMessageSignerException e) { 114 | log.error("Oauth failed", e); 115 | } catch (OAuthNotAuthorizedException e) { 116 | log.error("Oauth failed", e); 117 | } catch (OAuthExpectationFailedException e) { 118 | log.error("Oauth failed", e); 119 | } catch (OAuthCommunicationException e) { 120 | log.error("Oauth failed", e); 121 | } 122 | return null; 123 | } 124 | 125 | /** 126 | * Makes a signed OAuth call to twitter at this URL, authed as the current user. 127 | */ 128 | public String call(String urlAsString) { 129 | User user = currentUser.getUser(); 130 | 131 | return call(user, urlAsString); 132 | } 133 | 134 | public String call(User user, String urlAsString) { 135 | consumer.setTokenWithSecret(user.getTwitterAccessToken(), user.getTwitterTokenSecret()); 136 | 137 | // create an HTTP request to a protected resource 138 | URL url; 139 | try { 140 | url = new URL(urlAsString); 141 | HttpURLConnection connection = (HttpURLConnection) url.openConnection(); 142 | 143 | // sign the request 144 | consumer.sign(connection); 145 | 146 | // send the request 147 | connection.connect(); 148 | 149 | InputStream inputStream = connection.getInputStream(); 150 | if (connection.getResponseCode() == 200) { 151 | return IOUtils.toString(inputStream); 152 | } else { 153 | log.error("Twitter returned error code {} with message {}", connection.getResponseCode(), 154 | IOUtils.toString(inputStream)); 155 | } 156 | IOUtils.closeQuietly(inputStream); 157 | 158 | } catch (MalformedURLException e) { 159 | log.error("Could not perform Twitter OAuth request", e); 160 | } catch (OAuthExpectationFailedException e) { 161 | log.error("Could not perform Twitter OAuth request", e); 162 | } catch (OAuthCommunicationException e) { 163 | log.error("Could not perform Twitter OAuth request", e); 164 | } catch (OAuthMessageSignerException e) { 165 | log.error("Could not perform Twitter OAuth request", e); 166 | } catch (IOException e) { 167 | log.error("Could not perform Twitter OAuth request", e); 168 | } 169 | 170 | return null; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/twitter/TwitterAuthFilter.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth.twitter; 2 | 3 | import com.google.inject.Inject; 4 | import com.google.inject.Provider; 5 | import com.google.inject.Singleton; 6 | import com.wideplay.crosstalk.data.User; 7 | import com.wideplay.crosstalk.data.store.UserStore; 8 | import com.wideplay.crosstalk.web.CurrentUser; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | import javax.servlet.Filter; 13 | import javax.servlet.FilterChain; 14 | import javax.servlet.FilterConfig; 15 | import javax.servlet.ServletException; 16 | import javax.servlet.ServletRequest; 17 | import javax.servlet.ServletResponse; 18 | import javax.servlet.http.Cookie; 19 | import java.io.IOException; 20 | 21 | /** 22 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 23 | */ 24 | @Singleton 25 | public class TwitterAuthFilter implements Filter { 26 | private static final Logger log = LoggerFactory.getLogger(TwitterAuthFilter.class); 27 | 28 | @Inject 29 | private Provider userStoreProvider; 30 | 31 | @Inject 32 | private Provider currentUserProvider; 33 | 34 | public void init(FilterConfig filterConfig) throws ServletException { 35 | } 36 | 37 | public void doFilter(ServletRequest servletRequest, 38 | ServletResponse servletResponse, 39 | FilterChain filterChain) throws IOException, ServletException { 40 | UserStore userStore = userStoreProvider.get(); 41 | CurrentUser currentUser = currentUserProvider.get(); 42 | 43 | // First see if there is a session cookie. 44 | Cookie sessionCookie = currentUser.getSessionCookie(); 45 | if (null == sessionCookie) { 46 | // Auth as anonymous. 47 | currentUser.setUser(User.anonymous()); 48 | } else { 49 | // Find the user associated with this session cookie and log her in. 50 | User loggedIn = userStore.isLoggedIn(sessionCookie.getValue()); 51 | 52 | // No such user was found. (Invalid session cookie, continue as anonymous) 53 | if (null == loggedIn) { 54 | loggedIn = User.anonymous(); 55 | } 56 | 57 | currentUser.setUser(loggedIn); 58 | } 59 | 60 | filterChain.doFilter(servletRequest, servletResponse); 61 | } 62 | 63 | public void destroy() { 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/twitter/TwitterAuthModule.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth.twitter; 2 | 3 | import com.google.inject.AbstractModule; 4 | import com.google.inject.servlet.ServletModule; 5 | 6 | /** 7 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 8 | */ 9 | public class TwitterAuthModule extends AbstractModule { 10 | @Override 11 | protected void configure() { 12 | install(new ServletModule() { 13 | 14 | @Override 15 | protected void configureServlets() { 16 | filter("/r/*").through(TwitterAuthFilter.class); 17 | filter("/logout").through(TwitterAuthFilter.class); 18 | filter("/oauth/twitter").through(TwitterAuthFilter.class); // HACK! 19 | } 20 | 21 | }); 22 | bindConstant().annotatedWith(TwitterMode.class).to(true); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/twitter/TwitterMode.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth.twitter; 2 | 3 | import com.google.inject.BindingAnnotation; 4 | 5 | import java.lang.annotation.Retention; 6 | import java.lang.annotation.RetentionPolicy; 7 | 8 | /** 9 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 10 | */ 11 | @Retention(RetentionPolicy.RUNTIME) 12 | @BindingAnnotation 13 | public @interface TwitterMode { 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/auth/twitter/TwitterOAuthCallback.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.auth.twitter; 2 | 3 | import com.google.gson.Gson; 4 | import com.google.inject.Inject; 5 | import com.google.inject.Provider; 6 | import com.google.sitebricks.At; 7 | import com.google.sitebricks.headless.Reply; 8 | import com.google.sitebricks.headless.Request; 9 | import com.google.sitebricks.headless.Service; 10 | import com.google.sitebricks.http.Get; 11 | import com.wideplay.crosstalk.data.LoginToken; 12 | import com.wideplay.crosstalk.data.User; 13 | import com.wideplay.crosstalk.data.store.UserStore; 14 | import com.wideplay.crosstalk.data.twitter.TwitterUser; 15 | import com.wideplay.crosstalk.web.CurrentUser; 16 | import org.slf4j.Logger; 17 | import org.slf4j.LoggerFactory; 18 | 19 | import javax.servlet.http.Cookie; 20 | import javax.servlet.http.HttpServletResponse; 21 | 22 | /** 23 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 24 | */ 25 | @At("/oauth/twitter") @Service 26 | public class TwitterOAuthCallback { 27 | private static final Logger log = LoggerFactory.getLogger(TwitterOAuthCallback.class); 28 | 29 | @Inject 30 | private Provider currentUser; 31 | 32 | @Inject 33 | private Provider userStore; 34 | 35 | @Inject 36 | private Gson gson; 37 | 38 | @Get 39 | Reply callback(Twitter twitter, Request request, HttpServletResponse response) { 40 | String token = request.param("oauth_token"); 41 | String verifier = request.param("oauth_verifier"); 42 | 43 | String redirect = "/r/chat/1"; 44 | log.debug("Twitter callback successful with verifier {} ", verifier); 45 | 46 | if (verifier != null) { 47 | LoginToken loginToken = twitter.authorize(token, verifier); 48 | if (loginToken.getLastUrl() != null) { 49 | redirect = loginToken.getLastUrl(); 50 | } 51 | } else 52 | throw new IllegalStateException("OAuth callback called without verification."); 53 | 54 | // And now we should log this user in properly. 55 | CurrentUser thisUser = currentUser.get(); 56 | 57 | // Set session cookie. 58 | String sessionId = thisUser.newSessionId(); 59 | Cookie cookie = new Cookie(CurrentUser.SESSION_COOKIE_NAME, sessionId); 60 | cookie.setPath("/"); 61 | cookie.setMaxAge(60 * 60 * 24 /* 1 day */); 62 | response.addCookie(cookie); 63 | 64 | // We first need to some how get the username out of this so we can identify who it is! 65 | String creds = twitter.call("http://api.twitter.com/1/account/verify_credentials.json"); 66 | 67 | 68 | // Log user in, in our own user store. 69 | TwitterUser twitterUser = gson.fromJson(creds, TwitterUser.class); 70 | User user = new User(); 71 | user.setUsername(twitterUser.getScreenName()); 72 | user.setDisplayName(twitterUser.getName()); 73 | user.setAvatar(twitterUser.getProfileImageUrl()); 74 | user.setTwitterAccessToken(thisUser.getUser().getTwitterAccessToken()); 75 | user.setTwitterTokenSecret(thisUser.getUser().getTwitterTokenSecret()); 76 | 77 | userStore.get().loginAndMaybeCreate(sessionId, user); 78 | 79 | return Reply.saying().redirect(redirect) ; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/tasks/BackgroundBuzzService.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.tasks; 2 | 3 | import com.google.common.collect.ImmutableMap; 4 | import com.google.gson.Gson; 5 | import com.google.inject.Inject; 6 | import com.google.sitebricks.At; 7 | import com.google.sitebricks.headless.Reply; 8 | import com.google.sitebricks.headless.Service; 9 | import com.google.sitebricks.http.Get; 10 | import com.googlecode.objectify.Key; 11 | import com.wideplay.crosstalk.data.Message; 12 | import com.wideplay.crosstalk.data.Room; 13 | import com.wideplay.crosstalk.data.User; 14 | import com.wideplay.crosstalk.data.buzz.BuzzSearch; 15 | import com.wideplay.crosstalk.data.store.MessageStore; 16 | import com.wideplay.crosstalk.data.store.RoomStore; 17 | import com.wideplay.crosstalk.data.store.UserStore; 18 | import com.wideplay.crosstalk.web.Broadcaster; 19 | import com.wideplay.crosstalk.web.auth.buzz.BuzzApi; 20 | import org.slf4j.Logger; 21 | import org.slf4j.LoggerFactory; 22 | 23 | import java.net.URLEncoder; 24 | import java.util.List; 25 | 26 | /** 27 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 28 | */ 29 | @At("/queue/buzz") 30 | @Service 31 | public class BackgroundBuzzService { 32 | private static final Logger log = LoggerFactory.getLogger(BackgroundBuzzService.class); 33 | private static final int MAX_TERMS = 4; 34 | 35 | @Inject 36 | private RoomStore roomStore; 37 | 38 | @Inject 39 | private MessageStore messageStore; 40 | 41 | @Inject 42 | private UserStore userStore; 43 | 44 | @Inject 45 | private Broadcaster broadcaster; 46 | 47 | @Get 48 | Reply pullBuzzes(BuzzApi buzz, Gson gson) { 49 | // Do this for all rooms. 50 | List rooms = roomStore.list(); 51 | log.info("Starting background buzz fetch..."); 52 | 53 | // Pick an arbitrary user to be our buzz patsy. 54 | for (Room room : rooms) { 55 | Key userKey = room.getOccupancy().pickUser(); 56 | if (null == userKey) { 57 | // skip room, there's no one here. 58 | continue; 59 | } 60 | 61 | User patsy = userStore.fetch(userKey); 62 | log.info("Patsy found: {}!", patsy.getUsername()); 63 | 64 | int termsFetched = 0; 65 | for (String term : room.getOccupancy().getTerms()) { 66 | // This can get out of hand, so cap it. 67 | if (termsFetched > MAX_TERMS) { 68 | break; 69 | } 70 | 71 | term = URLEncoder.encode(term); 72 | String result = buzz.call(patsy, "https://www.googleapis.com/buzz/v1/activities/search?alt=json&key=AIzaSyBcWw4qozmY-WkvySl1iyuRNxQgxSh4awg&q=" + term); 73 | // Call to twitter can fail for various reasons. 74 | if (result != null && !result.isEmpty()) { 75 | BuzzSearch buzzes = gson.fromJson(result, BuzzSearch.Data.class).getData(); 76 | 77 | // Select a tweet and broadcast. 78 | if (room.getName().equals("japac")) { 79 | for (BuzzSearch.Buzz pick : buzzes.getItems()) { 80 | saveMessage(gson, room, term, buzzes, BuzzSearch.toMessage(pick)); 81 | } 82 | } else { 83 | 84 | Message pick = buzzes.pick(); 85 | if (saveMessage(gson, room, term, buzzes, pick)) 86 | continue; 87 | } 88 | } 89 | 90 | termsFetched++; 91 | } 92 | 93 | // Use this opportunity to perform room stateness evictions. 94 | room.getOccupancy().getUsers(); 95 | } 96 | 97 | // Chain next instance of this task. 98 | // TaskQueue.enqueueBuzzTask(); 99 | 100 | return Reply.saying().ok(); 101 | } 102 | 103 | private boolean saveMessage(Gson gson, Room room, String term, BuzzSearch buzzes, Message pick) { 104 | log.info("Found {} results for {}, picking buzz...", buzzes.getItems().size(), term); 105 | if (null != pick) { 106 | // Skip sending this tweet if it already exists in the room. 107 | Message message = messageStore.fetchMessage(pick.getId()); 108 | if (message != null && room.getId().equals(message.getRoomKey().getId())) { 109 | return true; 110 | } 111 | 112 | pick.setRoom(room); 113 | broadcaster.broadcast(room, null, gson.toJson( 114 | ImmutableMap.of( 115 | "rpc", "tweet", 116 | "post", pick) 117 | )); 118 | 119 | // If we liked this tweet, insert it into the room log. 120 | userStore.createGhost(pick.getAuthor()); 121 | messageStore.save(pick); 122 | } 123 | return false; 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/tasks/BackgroundTasksModule.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.tasks; 2 | 3 | import com.google.inject.AbstractModule; 4 | 5 | /** 6 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 7 | */ 8 | public class BackgroundTasksModule extends AbstractModule { 9 | @Override 10 | protected void configure() { 11 | bind(TaskQueue.class).asEagerSingleton(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/tasks/BackgroundTextClusterer.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.tasks; 2 | 3 | import com.google.common.base.Supplier; 4 | import com.google.common.collect.Lists; 5 | import com.google.common.collect.Maps; 6 | import com.google.common.collect.Multimap; 7 | import com.google.common.collect.Multimaps; 8 | import com.google.inject.Inject; 9 | import com.google.sitebricks.At; 10 | import com.google.sitebricks.headless.Reply; 11 | import com.google.sitebricks.headless.Service; 12 | import com.google.sitebricks.http.Get; 13 | import com.wideplay.crosstalk.data.Message; 14 | import com.wideplay.crosstalk.data.Room; 15 | import com.wideplay.crosstalk.data.RoomTextIndex; 16 | import com.wideplay.crosstalk.data.indexing.StopWords; 17 | import com.wideplay.crosstalk.data.store.MessageStore; 18 | import com.wideplay.crosstalk.data.store.RoomStore; 19 | import org.slf4j.Logger; 20 | import org.slf4j.LoggerFactory; 21 | 22 | import java.util.Collection; 23 | import java.util.Collections; 24 | import java.util.List; 25 | import java.util.Map; 26 | 27 | /** 28 | * Runs in the background and generates text clusters with simple counting. 29 | * 30 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 31 | */ 32 | @At("/queue/cluster") 33 | @Service 34 | public class BackgroundTextClusterer { 35 | private static final Logger log = LoggerFactory.getLogger(BackgroundTextClusterer.class); 36 | public static final int MAX_WORDS = 150; 37 | 38 | @Inject 39 | private StopWords stopWords; 40 | 41 | @Inject 42 | private RoomStore roomStore; 43 | 44 | @Inject 45 | private MessageStore messageStore; 46 | 47 | @Get 48 | Reply clusterPosts() { 49 | log.info("Starting background clustering..."); 50 | 51 | // Gather info about all rooms. 52 | RoomTextIndex globalIndex = new RoomTextIndex(); 53 | Map globalWordCount = Maps.newHashMap(); 54 | Map globalWordRooms = Maps.newHashMap(); 55 | globalIndex.setId(1L); 56 | 57 | List rooms = roomStore.list(); 58 | for (Room room : rooms) { 59 | List messages = messageStore.list(room); 60 | 61 | Map wordCount = Maps.newHashMap(); 62 | for (Message message : messages) { 63 | String text = message.getText(); 64 | String[] words = text.split("[ ,.-;:'\"()!?+*&]+"); 65 | for (String word : words) { 66 | word = word.toLowerCase(); 67 | 68 | // Stem word to its room form. 69 | // word = PorterStemmer.stem(word); // should we bother? 70 | 71 | if (!stopWords.isStopWord(word)) { 72 | Integer count = wordCount.get(word); 73 | Integer globalCount = globalWordCount.get(word); 74 | if (null == count) { 75 | count = 0; 76 | globalCount = 0; 77 | } 78 | 79 | wordCount.put(word, count + 1); 80 | globalWordCount.put(word, globalCount + 1); 81 | globalWordRooms.put(word, room); 82 | } 83 | } 84 | } 85 | 86 | List words = toWordList(wordCount); 87 | 88 | // O(N log N) 89 | Collections.sort(words); 90 | 91 | // Only keep 50 words around in our index. 92 | if (!words.isEmpty()) { 93 | words = words.subList(0, Math.min(words.size(), MAX_WORDS)); 94 | } 95 | 96 | // Update in datastore. We do the if null dance here, coz we 97 | // need to save this entity anyway, so no point in creating if absent. 98 | RoomTextIndex index = roomStore.indexOf(room); 99 | if (null == index) { 100 | index = new RoomTextIndex(); 101 | index.setRoom(room); 102 | } 103 | index.setWords(words); 104 | roomStore.save(index); 105 | } 106 | 107 | // Save global word count. 108 | List globalWords = toWordList(globalWordCount); 109 | Collections.sort(globalWords); 110 | 111 | // assign rooms to them. 112 | for (RoomTextIndex.WordTuple globalWord : globalWords) { 113 | globalWord.setRoomName(globalWordRooms.get(globalWord.getWord()).getName()); 114 | } 115 | 116 | globalIndex.setWords(globalWords); 117 | roomStore.save(globalIndex); 118 | 119 | // Chain next instance of this task. 120 | // TaskQueue.enqueueClusterTask(); 121 | 122 | return Reply.saying().ok(); 123 | } 124 | 125 | private static List toWordList(Map wordCount) { 126 | List words = Lists.newArrayListWithExpectedSize(wordCount.size()); 127 | for (Map.Entry entry : wordCount.entrySet()) { 128 | RoomTextIndex.WordTuple wordTuple = new RoomTextIndex.WordTuple(); 129 | wordTuple.set(entry.getKey(), entry.getValue()); 130 | words.add(wordTuple); 131 | } 132 | return words; 133 | } 134 | 135 | private static Multimap multimap() { 136 | return Multimaps.newListMultimap(Maps.>newHashMap(), 137 | new Supplier>() { 138 | public List get() { 139 | return Lists.newArrayList(); 140 | } 141 | }); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/tasks/BackgroundTweetService.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.tasks; 2 | 3 | import com.google.common.collect.ImmutableMap; 4 | import com.google.gson.Gson; 5 | import com.google.inject.Inject; 6 | import com.google.sitebricks.At; 7 | import com.google.sitebricks.headless.Reply; 8 | import com.google.sitebricks.headless.Service; 9 | import com.google.sitebricks.http.Get; 10 | import com.googlecode.objectify.Key; 11 | import com.wideplay.crosstalk.data.Message; 12 | import com.wideplay.crosstalk.data.Room; 13 | import com.wideplay.crosstalk.data.User; 14 | import com.wideplay.crosstalk.data.store.MessageStore; 15 | import com.wideplay.crosstalk.data.store.RoomStore; 16 | import com.wideplay.crosstalk.data.store.UserStore; 17 | import com.wideplay.crosstalk.data.twitter.TwitterSearch; 18 | import com.wideplay.crosstalk.web.Broadcaster; 19 | import com.wideplay.crosstalk.web.auth.twitter.Twitter; 20 | import org.slf4j.Logger; 21 | import org.slf4j.LoggerFactory; 22 | 23 | import java.net.URLEncoder; 24 | import java.util.List; 25 | 26 | /** 27 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 28 | */ 29 | @At("/queue/tweets") 30 | @Service 31 | public class BackgroundTweetService { 32 | private static final Logger log = LoggerFactory.getLogger(BackgroundTweetService.class); 33 | private static final int MAX_TERMS = 4; 34 | 35 | @Inject 36 | private RoomStore roomStore; 37 | 38 | @Inject 39 | private MessageStore messageStore; 40 | 41 | @Inject 42 | private UserStore userStore; 43 | 44 | @Inject 45 | private Broadcaster broadcaster; 46 | 47 | @Get 48 | Reply pullTweets(Twitter twitter, Gson gson) { 49 | // Do this for all rooms. 50 | List rooms = roomStore.list(); 51 | log.info("Starting background twitter fetch..."); 52 | 53 | // Pick an arbitrary user to be our twitter patsy. 54 | for (Room room : rooms) { 55 | Key userKey = room.getOccupancy().pickUser(); 56 | if (null == userKey) { 57 | // skip room, there's no one here. 58 | continue; 59 | } 60 | 61 | User patsy = userStore.fetch(userKey); 62 | log.info("Patsy found: {}!", patsy.getUsername()); 63 | 64 | int termsFetched = 0; 65 | for (String term : room.getOccupancy().getTerms()) { 66 | // This can get out of hand, so cap it. 67 | if (termsFetched > MAX_TERMS) { 68 | break; 69 | } 70 | 71 | term = URLEncoder.encode(term); 72 | String result = twitter.call(patsy, "http://search.twitter.com/search.json?q=" + term); 73 | // Call to twitter can fail for various reasons. 74 | if (result != null && !result.isEmpty()) { 75 | TwitterSearch tweets = gson.fromJson(result, TwitterSearch.class); 76 | 77 | // Select a tweet and broadcast. 78 | Message pick = tweets.pick(); 79 | 80 | if (null != pick) { 81 | // Skip sending this tweet if it already exists in the room. 82 | Message message = messageStore.fetchMessage(pick.getId()); 83 | if (message != null && room.getId().equals(message.getRoomKey().getId())) { 84 | continue; 85 | } 86 | 87 | pick.setRoom(room); 88 | broadcaster.broadcast(room, null, gson.toJson( 89 | ImmutableMap.of( 90 | "rpc", "tweet", 91 | "post", pick) 92 | )); 93 | 94 | // If we liked this tweet, insert it into the room log. 95 | userStore.createGhost(pick.getAuthor()); 96 | messageStore.save(pick); 97 | } 98 | } 99 | 100 | termsFetched++; 101 | } 102 | 103 | // Use this opportunity to perform room stateness evictions. 104 | room.getOccupancy().getUsers(); 105 | } 106 | 107 | // Chain next instance of this task. 108 | TaskQueue.enqueueTweetTask(); 109 | 110 | return Reply.saying().ok(); 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /src/main/java/com/wideplay/crosstalk/web/tasks/TaskQueue.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.web.tasks; 2 | 3 | import com.google.appengine.api.taskqueue.QueueFactory; 4 | import com.google.appengine.api.taskqueue.TaskOptions; 5 | import com.google.inject.Inject; 6 | import com.wideplay.crosstalk.web.auth.twitter.TwitterMode; 7 | 8 | /** 9 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 10 | */ 11 | class TaskQueue { 12 | @Inject 13 | public TaskQueue(@TwitterMode boolean twitterMode) { 14 | if (twitterMode) { 15 | enqueueTweetTask(); 16 | } else { 17 | enqueueBuzzTask(); 18 | } 19 | enqueueClusterTask(); 20 | } 21 | 22 | public static void enqueueClusterTask() { 23 | QueueFactory.getDefaultQueue().add(TaskOptions.Builder 24 | .withUrl("/queue/cluster") 25 | .method(TaskOptions.Method.GET) 26 | .countdownMillis(2 * 60 * 1000 /* 1 minute */)); 27 | } 28 | 29 | public static void enqueueTweetTask() { 30 | QueueFactory.getDefaultQueue().add(TaskOptions.Builder 31 | .withUrl("/queue/tweets") 32 | .method(TaskOptions.Method.GET) 33 | .countdownMillis(4 * 60 * 1000 /* minutes */)); 34 | } 35 | 36 | public static void enqueueBuzzTask() { 37 | QueueFactory.getDefaultQueue().add(TaskOptions.Builder 38 | .withUrl("/queue/buzz") 39 | .method(TaskOptions.Method.GET) 40 | .countdownMillis(1 * 60 * 1000 /* minutes */)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/resources/com/wideplay/crosstalk/data/indexing/stopwords.txt: -------------------------------------------------------------------------------- 1 | able 2 | about 3 | above 4 | abst 5 | accordance 6 | according 7 | accordingly 8 | across 9 | act 10 | actually 11 | added 12 | adj 13 | adopted 14 | affected 15 | affecting 16 | affects 17 | after 18 | afterwards 19 | again 20 | against 21 | all 22 | almost 23 | alone 24 | along 25 | already 26 | also 27 | although 28 | always 29 | among 30 | amongst 31 | and 32 | announce 33 | another 34 | any 35 | anybody 36 | anyhow 37 | anymore 38 | anyone 39 | anything 40 | anyway 41 | anyways 42 | anywhere 43 | apparently 44 | approximately 45 | are 46 | aren 47 | arent 48 | arise 49 | around 50 | aside 51 | ask 52 | asking 53 | auth 54 | available 55 | away 56 | awfully 57 | back 58 | became 59 | because 60 | become 61 | becomes 62 | becoming 63 | been 64 | before 65 | beforehand 66 | begin 67 | beginning 68 | beginnings 69 | begins 70 | behind 71 | being 72 | believe 73 | below 74 | beside 75 | besides 76 | between 77 | beyond 78 | biol 79 | both 80 | brief 81 | briefly 82 | but 83 | came 84 | can 85 | cannot 86 | can't 87 | cause 88 | causes 89 | certain 90 | certainly 91 | com 92 | come 93 | comes 94 | contain 95 | containing 96 | contains 97 | could 98 | couldnt 99 | coz 100 | cuz 101 | date 102 | data 103 | did 104 | didn't 105 | different 106 | differ 107 | does 108 | doesn't 109 | doing 110 | done 111 | don't 112 | down 113 | downwards 114 | due 115 | during 116 | each 117 | edu 118 | effect 119 | eight 120 | eighty 121 | either 122 | else 123 | elsewhere 124 | end 125 | ending 126 | enough 127 | especially 128 | et-al 129 | etc 130 | even 131 | ever 132 | every 133 | everybody 134 | everyone 135 | everything 136 | everywhere 137 | except 138 | far 139 | few 140 | fifth 141 | first 142 | five 143 | fix 144 | followed 145 | following 146 | follows 147 | for 148 | former 149 | formerly 150 | forth 151 | found 152 | four 153 | from 154 | further 155 | furthermore 156 | favorite 157 | gave 158 | get 159 | gets 160 | getting 161 | give 162 | given 163 | gives 164 | giving 165 | goes 166 | gone 167 | good 168 | got 169 | gotten 170 | had 171 | happens 172 | hardly 173 | has 174 | hasn't 175 | have 176 | haven't 177 | having 178 | hed 179 | hence 180 | her 181 | here 182 | hereafter 183 | hereby 184 | herein 185 | heres 186 | hereupon 187 | hers 188 | herself 189 | hes 190 | hid 191 | him 192 | himself 193 | his 194 | hither 195 | home 196 | how 197 | howbeit 198 | however 199 | hundred 200 | i'll 201 | immediate 202 | immediately 203 | importance 204 | important 205 | inc 206 | indeed 207 | index 208 | information 209 | instead 210 | into 211 | invention 212 | inward 213 | isn't 214 | itd 215 | it'll 216 | its 217 | itself 218 | i've 219 | just 220 | keep 221 | keeps 222 | kept 223 | keys 224 | know 225 | known 226 | knows 227 | largely 228 | last 229 | lately 230 | later 231 | latter 232 | latterly 233 | least 234 | less 235 | lest 236 | let 237 | lets 238 | like 239 | liked 240 | likely 241 | line 242 | little 243 | lmao 244 | look 245 | looking 246 | looks 247 | ltd 248 | made 249 | mainly 250 | make 251 | makes 252 | many 253 | may 254 | maybe 255 | mean 256 | means 257 | meantime 258 | meanwhile 259 | merely 260 | might 261 | million 262 | miss 263 | more 264 | moreover 265 | most 266 | mostly 267 | mrs 268 | much 269 | mug 270 | must 271 | myself 272 | name 273 | namely 274 | nay 275 | near 276 | nearly 277 | necessarily 278 | necessary 279 | need 280 | needs 281 | neither 282 | never 283 | nevertheless 284 | new 285 | next 286 | nine 287 | ninety 288 | nah 289 | nobody 290 | non 291 | none 292 | nonetheless 293 | noone 294 | nope 295 | nor 296 | normally 297 | nos 298 | not 299 | noted 300 | nothing 301 | now 302 | nowhere 303 | nup 304 | obtain 305 | obtained 306 | obviously 307 | off 308 | often 309 | 310 | 311 | okay 312 | old 313 | omitted 314 | 315 | once 316 | one 317 | ones 318 | only 319 | onto 320 | 321 | ord 322 | other 323 | others 324 | otherwise 325 | ought 326 | our 327 | ours 328 | ourselves 329 | out 330 | outside 331 | over 332 | overall 333 | owing 334 | own 335 | 336 | page 337 | pages 338 | part 339 | particular 340 | particularly 341 | past 342 | per 343 | perhaps 344 | placed 345 | please 346 | plus 347 | poorly 348 | possible 349 | possibly 350 | potentially 351 | 352 | predominantly 353 | present 354 | previously 355 | primarily 356 | probably 357 | promptly 358 | proud 359 | provides 360 | put 361 | 362 | que 363 | quickly 364 | quite 365 | start 366 | state 367 | ran 368 | rather 369 | readily 370 | really 371 | recent 372 | recently 373 | ref 374 | refs 375 | regarding 376 | regardless 377 | regards 378 | related 379 | relatively 380 | research 381 | respectively 382 | resulted 383 | resulting 384 | results 385 | right 386 | run 387 | rofl 388 | said 389 | same 390 | saw 391 | say 392 | saying 393 | says 394 | sec 395 | section 396 | see 397 | seeing 398 | seem 399 | seemed 400 | seeming 401 | seems 402 | seen 403 | self 404 | selves 405 | sent 406 | seven 407 | several 408 | shall 409 | she 410 | shed 411 | she'll 412 | shes 413 | should 414 | shouldn't 415 | show 416 | showed 417 | shown 418 | showns 419 | shows 420 | significant 421 | significantly 422 | similar 423 | similarly 424 | since 425 | six 426 | slightly 427 | some 428 | somebody 429 | somehow 430 | someone 431 | somethan 432 | something 433 | sometime 434 | sometimes 435 | somewhat 436 | somewhere 437 | soon 438 | sorry 439 | specifically 440 | specified 441 | specify 442 | specifying 443 | state 444 | states 445 | still 446 | stuff 447 | stop 448 | strongly 449 | sub 450 | substantially 451 | successfully 452 | such 453 | sucks 454 | sufficiently 455 | suggest 456 | sup 457 | sure 458 | 459 | take 460 | taken 461 | taking 462 | tell 463 | tends 464 | 465 | than 466 | thank 467 | thanks 468 | thanx 469 | that 470 | that'll 471 | thats 472 | that've 473 | the 474 | their 475 | theirs 476 | them 477 | themselves 478 | then 479 | thence 480 | there 481 | thereafter 482 | thereby 483 | thered 484 | therefore 485 | therein 486 | there'll 487 | thereof 488 | therere 489 | theres 490 | thereto 491 | thereupon 492 | there've 493 | these 494 | they 495 | theyd 496 | they'll 497 | theyre 498 | they've 499 | thing 500 | think 501 | this 502 | those 503 | thou 504 | though 505 | thoughh 506 | thousand 507 | throug 508 | through 509 | throughout 510 | thru 511 | thus 512 | til 513 | tip 514 | together 515 | too 516 | took 517 | toward 518 | towards 519 | tried 520 | tries 521 | truly 522 | try 523 | trying 524 | 525 | twice 526 | two 527 | 528 | 529 | under 530 | unfortunately 531 | unless 532 | unlike 533 | unlikely 534 | until 535 | unto 536 | 537 | upon 538 | ups 539 | 540 | use 541 | used 542 | useful 543 | usefully 544 | usefulness 545 | uses 546 | using 547 | usually 548 | 549 | value 550 | various 551 | 've 552 | very 553 | via 554 | viz 555 | vol 556 | vols 557 | 558 | 559 | want 560 | wants 561 | was 562 | wasn't 563 | way 564 | w00t 565 | woot 566 | wed 567 | welcome 568 | we'll 569 | went 570 | were 571 | weren't 572 | we've 573 | what 574 | whatever 575 | what'll 576 | whats 577 | when 578 | whence 579 | whenever 580 | where 581 | whereafter 582 | whereas 583 | whereby 584 | wherein 585 | wheres 586 | whereupon 587 | wherever 588 | whether 589 | which 590 | while 591 | whim 592 | whither 593 | who 594 | whod 595 | whoever 596 | whole 597 | who'll 598 | whom 599 | whomever 600 | whos 601 | whose 602 | why 603 | widely 604 | willing 605 | wish 606 | with 607 | within 608 | without 609 | won't 610 | words 611 | world 612 | would 613 | wouldn't 614 | www 615 | yes 616 | yet 617 | you 618 | youd 619 | you'll 620 | your 621 | youre 622 | yours 623 | yourself 624 | yourselves 625 | you've 626 | zero 627 | zip 628 | zilch 629 | -------------------------------------------------------------------------------- /src/main/webapp/AboutPage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CrossTalk 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |

CrossTalk

18 |

Real time chat for events

19 |

About Us

20 |
21 |
22 |
CrossTalk is a realtime group chat product for cool events like webstock.
23 |
It was built by 24 | @themaninblue and 25 | @dhanji, from Google.
26 |
Share your thoughts with us in the CrossTalk Room!
27 |
28 |
29 |
30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /src/main/webapp/HomePage.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CrossTalk 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |

CrossTalk

21 |

Real time chat for events

22 |

Create a new chat room

23 |
24 | 28 |
29 |

Click Tap on the room you'd like to visit: or create a new chat room

30 |
    31 | 32 | @Repeat(items=rooms, var="room") 33 |
  • 34 |
    35 | ${room.displayName} 36 | 37 | 38 | 39 | 40 |
    41 |

    ${__page.contributors(room)} Contributors:

    42 |
      43 | 47 | @Repeat(items=__page.occupants(room), var="user") 48 |
    • ${user.displayName.split(' ')[0]}${!isLast ? ', ' : ''}
    • 49 |
    50 |

    51 |
    52 |
    53 |

    People are talking about

    54 |
      55 | @Repeat(items=__page.trends(room), var="trend") 56 |
    1. ${trend.word}
    2. 57 | 58 |
    59 |
    60 |
    61 |
  • 62 |
63 |
64 |
65 |

Click / tap on a topic you'd like to explore:

66 |
    67 |
68 |
69 |
70 | 71 | 72 | -------------------------------------------------------------------------------- /src/main/webapp/RoomAdminPage.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | Administer Rooms 6 | 7 | 8 | 9 |
10 |
New room:
11 |
12 |
Name:
13 |
Host: Alan Noble
14 |
Start: (yyyy-MM-dd HH:mm)
15 |
End: (e.g.: 2011-02-18 13:30)
16 | 17 |
18 |
19 | 20 |
21 | @Repeat(items=rooms, var="room") 22 |
23 | ${room.name} 24 |
25 | (current: ${room.occupancy.users}) activity >> ${room.occupancy.segments} 26 |
27 | 28 | 29 | (leaves orphaned posts) 30 |
31 |
32 |
33 |
34 | 35 | -------------------------------------------------------------------------------- /src/main/webapp/RoomPage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 27 |

CrossTalk

28 | 38 |
39 |
40 | @Repeat(items=room.occupancy.segments, var="segment") 41 |
43 | ${ __page.activity(segment) } 44 |
45 |
46 | 70 |
71 |
72 |
73 | @Repeat(items=messages, var="post") 74 |
75 |
${post.author.username}
76 | 77 |
78 | 79 |
${post.text}
80 | @ShowIf(post.attachmentId != null) 81 |
82 | 83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 | 91 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/appengine-web.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | crosstalkchat 4 | 3 5 | 6 | 7 | 8 | 9 | 10 | 11 | true 12 | 13 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/datastore-indexes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/web.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | crosstalk 7 | 8 | webFilter 9 | com.google.inject.servlet.GuiceFilter 10 | 11 | 12 | 13 | webFilter 14 | /* 15 | 16 | 17 | 18 | 19 | 0 20 | 21 | 22 | 23 | com.wideplay.crosstalk.web.SitebricksConfig 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/main/webapp/crosstalk.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | } 4 | 5 | .invisible { 6 | display: none; 7 | } 8 | 9 | body { 10 | background-color: #4D5158; 11 | background-image: url(/images/body.gif); 12 | color: #ffffff; 13 | line-height: 1.4; 14 | font-family: Arial, Helvetica, sans-serif; 15 | font-size: 85%; 16 | 17 | /*background-color: #eee;*/ 18 | /*background-color: #c8d6ff;*/ 19 | /*background: -webkit-gradient(*/ 20 | /*linear,*/ 21 | /*left top,*/ 22 | /*left bottom,*/ 23 | /*color-stop(0.0, #c6c6c6),*/ 24 | /*color-stop(0.5, #fefefe),*/ 25 | /*color-stop(1.0, #c6c6c6)*/ 26 | /*);*/ 27 | } 28 | 29 | textarea, input { 30 | font-size: 100%; 31 | font-family: Arial, Helvetica, sans-serif; 32 | } 33 | 34 | #mobileCheck { 35 | position: absolute; 36 | width: 0; 37 | height: 0; 38 | } 39 | 40 | h1 { 41 | position: absolute; 42 | right: 10px; 43 | bottom: 10px; 44 | font-size: 125%; 45 | font-family: GS, Arial, Helvetica, sans-serif; 46 | font-weight: normal; 47 | } 48 | 49 | #header { 50 | position: absolute; 51 | top: 0; 52 | right: 0; 53 | left: 0; 54 | height: 80px; 55 | } 56 | 57 | #header h2 { 58 | position: absolute; 59 | top: 17px; 60 | left: 6.25em; 61 | right: 30%; 62 | overflow: hidden; 63 | color: white; 64 | font-size: 160%; 65 | font-family: GS, Arial, Helvetica, sans-serif; 66 | font-weight: normal; 67 | text-transform: uppercase; 68 | white-space: nowrap; 69 | text-overflow: ellipse; 70 | } 71 | 72 | #tabs { 73 | position: absolute; 74 | left: 10em; 75 | bottom: 0; 76 | margin: 0; 77 | padding: 0; 78 | list-style: none; 79 | } 80 | 81 | #tabs li { 82 | float: left; 83 | margin: 0 5px 0 0; 84 | padding: 0; 85 | } 86 | 87 | #tabs li.on { 88 | position: relative; 89 | z-index: 10; 90 | font-weight: bold; 91 | } 92 | 93 | #tabs a { 94 | display: block; 95 | -webkit-border-top-left-radius: 4px; 96 | -webkit-border-top-right-radius: 4px; 97 | -moz-border-radius-topleft: 4px; 98 | -moz-border-radius-topright: 4px; 99 | border-top-left-radius: 4px; 100 | border-top-right-radius: 4px; 101 | padding: 0 20px 0 20px; 102 | background-color: #CED2D9; 103 | color: #4c4c4c; 104 | line-height: 30px; 105 | text-decoration: none; 106 | } 107 | 108 | #tabs li.on a { 109 | background-color: #ffffff; 110 | } 111 | 112 | #linkBack { 113 | position: absolute; 114 | right: 20px; 115 | top: 22px; 116 | } 117 | 118 | #linkBack a { 119 | color: #ffffff; 120 | } 121 | 122 | #footer { 123 | position: absolute; 124 | bottom: 0; 125 | right: 0; 126 | left: 0; 127 | height: 130px; 128 | } 129 | 130 | #center { 131 | position: absolute; 132 | top: 60px; 133 | left: 0; 134 | right: 0; 135 | bottom: 130px; 136 | -webkit-box-shadow: 0 0 15px rgba(0,0,0,0.7); 137 | -moz-box-shadow: 0 0 15px rgba(0,0,0,0.7); 138 | box-shadow: 0 0 15px rgba(0,0,0,0.7); 139 | background: #ced2d9; 140 | } 141 | 142 | #activity-map { 143 | position: absolute; 144 | left: 70%; 145 | bottom: 2px; 146 | width: 120px; 147 | margin-left: 5px; 148 | } 149 | 150 | #activity-map .segment { 151 | overflow: hidden; 152 | padding: 1px 0; 153 | } 154 | 155 | #activity-map .bubble { 156 | float: left; 157 | background: #B3B8C5; 158 | -webkit-border-radius: 500px; 159 | -moz-border-radius: 500px; 160 | border-radius: 500px; 161 | height: 7px; 162 | width: 7px; 163 | margin-right: 3px; 164 | } 165 | 166 | #viewport { 167 | position: absolute; 168 | top: 0; 169 | left: 0; 170 | right: 30%; 171 | bottom: 0; 172 | 173 | -webkit-box-shadow: 0 0 12px rgba(0,0,0,0.2); 174 | -moz-box-shadow: 0 0 12px rgba(0,0,0,0.2); 175 | box-shadow: 0 0 12px rgba(0,0,0,0.2); 176 | 177 | overflow: auto; 178 | } 179 | 180 | #stream { 181 | width: 100%; 182 | overflow: hidden; 183 | } 184 | 185 | #stream > .inner { 186 | min-height: 600px; 187 | margin: 0 0 0 10em; 188 | background-color: #ffffff; 189 | color: #4c4c4c; 190 | -webkit-box-shadow: 0 0 12px rgba(0,0,0,0.2); 191 | -moz-box-shadow: 0 0 12px rgba(0,0,0,0.2); 192 | box-shadow: 0 0 12px rgba(0,0,0,0.2); 193 | } 194 | 195 | #viewport .message { 196 | position: relative; 197 | margin: 0 20px 0 47px; 198 | margin-left: 20px; /* In the absence of avatars */ 199 | } 200 | 201 | #viewport .message.tweet { 202 | background-color: #DEF7FF; 203 | } 204 | 205 | #viewport .message .author { 206 | position: absolute; 207 | right: 100%; 208 | width: 10em; 209 | margin: 1em 37px 0 0; 210 | color: rgba(0,0,0,0.33); 211 | text-align: right; 212 | } 213 | 214 | #viewport .message.tweet .author { 215 | position: static; 216 | width: auto; 217 | margin: 0; 218 | padding: 1em 10px 0 10px; 219 | border-top: 1px solid #CAE2EA; 220 | color: #747F83; 221 | text-align: left; 222 | font-weight: bold; 223 | } 224 | 225 | #viewport .message.tweet .author:before { 226 | content: 'Buzz by '; 227 | } 228 | 229 | .avatar { 230 | width: 24px; 231 | height: 24px; 232 | border: 2px solid #ffffff; 233 | -webkit-border-radius: 2px; 234 | -moz-border-radius: 2px; 235 | border-radius: 2px; 236 | -webkit-box-shadow: 3px 3px 3px rgba(0,0,0,0.15); 237 | -moz-box-shadow: 3px 3px 3px rgba(0,0,0,0.15); 238 | box-shadow: 3px 3px 3px rgba(0,0,0,0.15); 239 | } 240 | 241 | #viewport .message .avatar { 242 | position: relative; 243 | left: -37px; 244 | display: block; 245 | float: left; 246 | margin: 0.6em -27px -20px 0; 247 | } 248 | 249 | #viewport .message.tweet .avatar { 250 | top: -2.4em; 251 | } 252 | 253 | #viewport .message time { 254 | float: right; 255 | margin-left: 1em; 256 | color: #aaa; 257 | } 258 | 259 | #viewport .message.tweet time { 260 | display: none; 261 | } 262 | 263 | #viewport .message .content { 264 | border-top: 1px solid #e5e5e5; 265 | padding: 1em 0 1em 0; 266 | } 267 | 268 | #viewport .message:first-child .content { 269 | border-top: 0 none #ffffff !important; 270 | } 271 | 272 | #viewport .message.tweet .content { 273 | border-top: 0 none #ffffff; 274 | padding: 0 10px 1em 10px; 275 | } 276 | 277 | #viewport .message .images { 278 | margin-top: 1.4em; 279 | } 280 | 281 | time + .images { 282 | margin-top: 0 !important; 283 | } 284 | 285 | #viewport .message .images img { 286 | max-width: 90%; 287 | border: 6px solid #ffffff; 288 | -webkit-box-shadow: 0 0 1px rgba(0,0,0,0.3), 3px 3px 6px rgba(0,0,0,0.1); 289 | -moz-box-shadow: 0 0 1px rgba(0,0,0,0.3), 3px 3px 6px rgba(0,0,0,0.1); 290 | box-shadow: 0 0 1px rgba(0,0,0,0.3), 3px 3px 6px rgba(0,0,0,0.1); 291 | } 292 | 293 | #youLabel { 294 | position: absolute; 295 | top: -1px; 296 | right: 100%; 297 | margin-right: 57px; 298 | font-weight: bold; 299 | text-align: right; 300 | } 301 | 302 | .talkbox { 303 | position: absolute; 304 | left: 10em; 305 | right: 30%; 306 | top: 10px; 307 | bottom: 10px; 308 | margin-right: 15px; 309 | border: 2px solid #fff; 310 | -webkit-border-radius: 4px; 311 | -moz-border-radius: 4px; 312 | border-radius: 4px; 313 | background: #ced2d9; 314 | -moz-box-shadow: inset 3px 3px 3px rgba(0,0,0,0.25); 315 | -webkit-box-shadow: inset 1px 1px 3px rgba(0,0,0,0.33); 316 | box-shadow: inset 3px 3px 3px rgba(0,0,0,0.25); 317 | } 318 | 319 | .talkbox .signin { 320 | color: #67696C; 321 | font-size: 125%; 322 | text-align: center; 323 | line-height: 106px; 324 | } 325 | 326 | .talkbox .signin img { 327 | margin-right: 0.5em; 328 | vertical-align: middle; 329 | } 330 | 331 | .talkbox .signin a { 332 | color: #67696C; 333 | font-weight: bold; 334 | } 335 | 336 | .talkbox > .inner { 337 | position: absolute; 338 | top: 1em; 339 | left: 45px; 340 | right: 18px; 341 | bottom: 10px; 342 | } 343 | 344 | .talkbox > .inner > .avatar { 345 | position: absolute; 346 | left: -37px; 347 | top: -0.4em; 348 | } 349 | 350 | .talkbox textarea { 351 | width: 100%; 352 | height: 4em; 353 | border: 0 none #ffffff; 354 | outline: 0 none #ffffff; 355 | padding: 0; 356 | background-color: transparent; 357 | color: #4c4c4c; 358 | resize: none; 359 | } 360 | 361 | .button { 362 | -webkit-border-radius: 3px; 363 | -moz-border-radius: 3px; 364 | border: 0 none #ffffff; 365 | border-radius: 3px; 366 | padding: 0.5em 1em 0.3em 1em; 367 | background-color: #b2b2b2; 368 | background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #cccccc), color-stop(1, #999999)); 369 | background-image: -moz-linear-gradient(center center, #cccccc 0%, #999999 100%); 370 | color: #4c4c4c; 371 | font-size: 85%; 372 | text-shadow: 1px 1px 0 rgba(255,255,255,0.33); 373 | -webkit-box-shadow: 1px 1px 0 rgba(0,0,0,0.4); 374 | -moz-box-shadow: 1px 1px 0 rgba(0,0,0,0.4); 375 | box-shadow: 1px 1px 0 rgba(0,0,0,0.4); 376 | cursor: pointer; 377 | } 378 | 379 | #post-message { 380 | position: absolute; 381 | right: 0; 382 | bottom: 0; 383 | width: 9em; 384 | } 385 | 386 | .qq-upload-list { 387 | list-style-type: none; 388 | position: absolute; 389 | color: white; 390 | } 391 | 392 | #right { 393 | position: absolute; 394 | right: 0; 395 | width: 17%; 396 | padding: 20px 20px 20px 20px; 397 | color: #696B6F; 398 | text-align: right; 399 | } 400 | 401 | #right h2 { 402 | margin-top: 1.4em; 403 | font-size: 100%; 404 | } 405 | 406 | #right h2:first-of-type { 407 | margin-top: 0; 408 | } 409 | 410 | #right p { 411 | margin-top: -8px; 412 | } 413 | 414 | .current-contributor-avatars { 415 | margin-top: 0.2em; 416 | } 417 | 418 | .current-contributor-avatars img { 419 | display: inline-block; 420 | margin: 0 0 6px 6px; 421 | } 422 | 423 | #hashtags div:last-child { 424 | margin-top: 0.3em; 425 | } -------------------------------------------------------------------------------- /src/main/webapp/crosstalk_mobile.css: -------------------------------------------------------------------------------- 1 | 2 | /* GENERAL ========================================= */ 3 | 4 | html { 5 | height: auto; 6 | } 7 | 8 | body { 9 | height: auto; 10 | overflow: auto; 11 | margin: 0 auto; 12 | padding: 0 10px 20px 10px; 13 | } 14 | 15 | #mobileCheck { 16 | display: none; 17 | } 18 | 19 | #page { 20 | width: auto; 21 | height: auto; 22 | overflow: auto; 23 | } 24 | 25 | 26 | 27 | 28 | /* HEADER ========================================= */ 29 | 30 | h1 31 | { 32 | position: static; 33 | width: 100%; 34 | margin: 0 0 0 -10px; 35 | padding: 0 10px; 36 | background-color: #ffffff; 37 | text-indent: -9999px; 38 | height: 41px; 39 | background-image: url(/images/logo_mobile.gif); 40 | background-repeat: no-repeat; 41 | background-position: 10px 10px; 42 | -webkit-box-shadow: 0 0 15px rgba(0,0,0,0.5); 43 | -moz-box-shadow: 0 0 15px rgba(0,0,0,0.5); 44 | box-shadow: 0 0 15px rgba(0,0,0,0.5); 45 | } 46 | 47 | #header { 48 | position: static; 49 | width: auto; 50 | max-width: 768px; 51 | height: auto; 52 | margin: 0 auto; 53 | } 54 | 55 | #header h2 { 56 | position: static; 57 | margin-top: 20px; 58 | } 59 | 60 | #linkBack { 61 | top: 13px; 62 | } 63 | 64 | #linkBack a { 65 | color: #4D5158; 66 | } 67 | 68 | #tabs { 69 | display: none; 70 | } 71 | 72 | 73 | 74 | 75 | /* CONTENT ========================================= */ 76 | 77 | #center { 78 | position: static; 79 | max-width: 768px; 80 | margin: 10px auto 0 auto; 81 | -webkit-border-radius: 6px; 82 | -moz-border-radius: 6px; 83 | border-radius: 6px; 84 | } 85 | 86 | #right { 87 | position: static; 88 | width: auto; 89 | padding: 10px 20px 16px 10px; 90 | text-align: left; 91 | } 92 | 93 | #activity-map { 94 | display: none; 95 | } 96 | 97 | #right h2 { 98 | display: none; 99 | } 100 | 101 | #right h2:first-of-type { 102 | display: inline; 103 | } 104 | 105 | .current-contributor-avatars { 106 | display: inline; 107 | } 108 | 109 | .current-contributor-avatars img { 110 | vertical-align: middle; 111 | } 112 | 113 | #hashtags { 114 | display: none; 115 | } 116 | 117 | #viewport { 118 | position: static; 119 | } 120 | 121 | #stream > .inner { 122 | margin-left: 0; 123 | -webkit-border-bottom-right-radius: 6px; 124 | -webkit-border-bottom-left-radius: 6px; 125 | -moz-border-radius-bottomright: 6px; 126 | -moz-border-radius-bottomleft: 6px; 127 | border-bottom-right-radius: 6px; 128 | border-bottom-left-radius: 6px; 129 | padding-bottom: 10px; 130 | } 131 | 132 | #viewport .message .author { 133 | position: static; 134 | width: auto; 135 | text-align: left; 136 | margin: 0; 137 | border-top: 1px solid #e5e5e5; 138 | padding: 1em 0 0 0; 139 | } 140 | 141 | #viewport .message:first-of-type .author { 142 | border-top: 0 none #ffffff; 143 | } 144 | 145 | #viewport .message .avatar, #viewport .message.tweet .avatar { 146 | top: 0; 147 | margin-top: -1.9em; 148 | } 149 | 150 | #viewport .message time { 151 | position: relative; 152 | top: -1.4em; 153 | } 154 | 155 | #viewport .message .content { 156 | padding-top: 0; 157 | border-top: 0 none #ffffff !important; 158 | } 159 | 160 | 161 | 162 | 163 | /* FOOTER ========================================= */ 164 | 165 | #footer { 166 | max-width: 768px; 167 | margin: 10px auto 0 auto; 168 | position: static; 169 | } 170 | 171 | .talkbox { 172 | position: relative; 173 | top: 0; 174 | left: 0; 175 | right: 0; 176 | bottom: 0; 177 | height: 106px; 178 | margin: 0; 179 | } -------------------------------------------------------------------------------- /src/main/webapp/fileuploader.css: -------------------------------------------------------------------------------- 1 | #uploadButton { 2 | font-size: 100%; 3 | position: absolute; 4 | right: 9em; 5 | bottom: 0; 6 | } 7 | 8 | .qq-uploader { position:relative; width: 100%;} 9 | 10 | .qq-upload-button, .qq-upload-button-hover { 11 | width: 8em; 12 | -webkit-border-radius: 3px; 13 | -moz-border-radius: 3px; 14 | border-radius: 3px; 15 | padding: 0.4em 1em 0.2em 1em; 16 | background-color: #b2b2b2; 17 | background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #cccccc), color-stop(1, #999999)) !important; 18 | background-image: -moz-linear-gradient(center center, #cccccc 0%, #999999 100%) !important; 19 | color: #4c4c4c; 20 | font-size: 85%; 21 | text-shadow: 1px 1px 0 rgba(255,255,255,0.33); 22 | -webkit-box-shadow: 1px 1px 0 rgba(0,0,0,0.4); 23 | -moz-box-shadow: 1px 1px 0 rgba(0,0,0,0.4); 24 | box-shadow: 1px 1px 0 rgba(0,0,0,0.4); 25 | cursor: pointer; 26 | } 27 | 28 | #icon-upload { 29 | float: left; 30 | width: 12px; 31 | height: 12px; 32 | padding-right: 8px; 33 | margin-top: 1px; 34 | background: url(/images/arrow_up.png) no-repeat; 35 | } 36 | .qq-upload-button-hover { background: #4b93ff; } 37 | .qq-upload-button-focus { outline:1px dotted black; } 38 | 39 | .qq-upload-drop-area { 40 | position:absolute; 41 | top: -80px; 42 | left: -10px; 43 | right: -10px; 44 | bottom: -30px; 45 | 46 | text-shadow: -1px 1px 1px #eee; 47 | z-index: 2; 48 | background: #bbb; 49 | text-align:center; 50 | -webkit-border-radius: 4px; 51 | -moz-border-radius: 4px; 52 | border-radius: 4px; 53 | } 54 | .qq-upload-drop-area span { 55 | display:block; position:absolute; top: 50%; width:100%; margin-top:-8px; font-size:16px; 56 | } 57 | .qq-upload-drop-area-active { background: #8ab1ff;} 58 | 59 | .qq-upload-list { 60 | position: absolute; 61 | right: 9em; 62 | bottom: 0; 63 | margin: 0; 64 | padding: 0; 65 | list-style:none; 66 | color: #4c4c4c; 67 | } 68 | .qq-upload-list li { margin:0; padding:0; line-height:15px; font-size:12px;} 69 | .qq-upload-file, .qq-upload-spinner, .qq-upload-size, .qq-upload-cancel, .qq-upload-failed-text { 70 | margin-right: 7px; 71 | } 72 | 73 | .qq-upload-file {} 74 | .qq-upload-spinner {display:inline-block; background: url("loading.gif"); width:15px; height:15px; vertical-align:text-bottom;} 75 | .qq-upload-size,.qq-upload-cancel {font-size:11px;} 76 | 77 | .qq-upload-failed-text {display:none;} 78 | .qq-upload-fail .qq-upload-failed-text {display:inline;} -------------------------------------------------------------------------------- /src/main/webapp/fonts/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'GS'; 3 | src: url('/fonts/gill_sans_mt-webfont.eot?') format('eot'), 4 | url('/fonts/gill_sans_mt-webfont.woff') format('woff'), 5 | url('/fonts/gill_sans_mt-webfont.ttf') format('truetype'), 6 | url('/fonts/gill_sans_mt-webfont.svg#webfontwV7eqL9a') format('svg'); 7 | font-weight: normal; 8 | font-style: normal; 9 | } -------------------------------------------------------------------------------- /src/main/webapp/fonts/gill_sans_mt-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/fonts/gill_sans_mt-webfont.eot -------------------------------------------------------------------------------- /src/main/webapp/fonts/gill_sans_mt-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/fonts/gill_sans_mt-webfont.ttf -------------------------------------------------------------------------------- /src/main/webapp/fonts/gill_sans_mt-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/fonts/gill_sans_mt-webfont.woff -------------------------------------------------------------------------------- /src/main/webapp/images/arrow_left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/arrow_left.png -------------------------------------------------------------------------------- /src/main/webapp/images/arrow_right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/arrow_right.png -------------------------------------------------------------------------------- /src/main/webapp/images/arrow_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/arrow_up.png -------------------------------------------------------------------------------- /src/main/webapp/images/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/avatar.png -------------------------------------------------------------------------------- /src/main/webapp/images/body.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/body.gif -------------------------------------------------------------------------------- /src/main/webapp/images/cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/cloud.png -------------------------------------------------------------------------------- /src/main/webapp/images/cloud_pointer1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/cloud_pointer1.png -------------------------------------------------------------------------------- /src/main/webapp/images/cloud_pointer2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/cloud_pointer2.png -------------------------------------------------------------------------------- /src/main/webapp/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/logo.png -------------------------------------------------------------------------------- /src/main/webapp/images/logo_mobile.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/logo_mobile.gif -------------------------------------------------------------------------------- /src/main/webapp/images/new_room_arrow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/new_room_arrow.gif -------------------------------------------------------------------------------- /src/main/webapp/images/pen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/pen.png -------------------------------------------------------------------------------- /src/main/webapp/images/room_status_green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/room_status_green.png -------------------------------------------------------------------------------- /src/main/webapp/images/room_status_green_lrg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/room_status_green_lrg.png -------------------------------------------------------------------------------- /src/main/webapp/images/room_status_grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/room_status_grey.png -------------------------------------------------------------------------------- /src/main/webapp/images/room_status_grey_lrg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/room_status_grey_lrg.png -------------------------------------------------------------------------------- /src/main/webapp/images/room_status_orange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/room_status_orange.png -------------------------------------------------------------------------------- /src/main/webapp/images/room_status_orange_lrg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/room_status_orange_lrg.png -------------------------------------------------------------------------------- /src/main/webapp/images/swish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/swish.png -------------------------------------------------------------------------------- /src/main/webapp/images/twitter1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/twitter1.jpg -------------------------------------------------------------------------------- /src/main/webapp/images/twitter_login.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/twitter_login.gif -------------------------------------------------------------------------------- /src/main/webapp/images/webstock.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/webstock.gif -------------------------------------------------------------------------------- /src/main/webapp/images/webstock_mobile.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/main/webapp/images/webstock_mobile.gif -------------------------------------------------------------------------------- /src/main/webapp/js/crosstalk.js: -------------------------------------------------------------------------------- 1 | /** 2 | * crosstalk.me by Dhanji and Cameron. 3 | */ 4 | 5 | var crosstalk = crosstalk || {}; 6 | 7 | /** 8 | * Noop function. 9 | */ 10 | crosstalk.noop = function() {}; 11 | 12 | $(document).ready(function() { 13 | 14 | // Callbacks from server. 15 | crosstalk.callbacks = { 16 | 'receive': crosstalk.receiveMessage_, 17 | 'join': crosstalk.joinRoom_, 18 | 'leave': crosstalk.leaveRoom_, 19 | 'tweet': crosstalk.tweetArrives_ 20 | }; 21 | 22 | // Setup Comet channel. 23 | var token = $('#comet-token').html(); 24 | 25 | // Set up coment channel. 26 | var channel = new goog.appengine.Channel(token); 27 | var socket = channel.open(); 28 | socket.onopen = function() { }; 29 | socket.onmessage = function(data) { 30 | // Receive as JSON. 31 | data = eval('(' + data.data + ')'); 32 | 33 | crosstalk.callbacks[data.rpc](data); 34 | }; 35 | socket.onerror = crosstalk.noop; 36 | socket.onclose = crosstalk.noop; 37 | 38 | // Initialize editor. 39 | crosstalk.init_(); 40 | 41 | if ($('#mobileCheck').css('display') != 'none') { 42 | setTimeout(scrollToBottom, 1000); 43 | } 44 | }); 45 | 46 | function scrollToBottom() { 47 | if ($('#mobileCheck').css('display') == 'none') { 48 | window.scrollTo(0, 100000); 49 | } 50 | else { 51 | $('#viewport').get(0).scrollTop = 100000; 52 | } 53 | } 54 | 55 | /** 56 | * Initialize UI event handlers and such. 57 | */ 58 | crosstalk.init_ = function () { 59 | // Size the initial height of the stream 60 | $('#stream > .inner').css('minHeight', $('#viewport').outerHeight()); 61 | 62 | // Linkify all pre-rendered content. 63 | $('.message').each(function() { 64 | var msg = $(this); 65 | var textRef = $('.content > .text', msg); 66 | var linkset = crosstalk.linkify(textRef.text()); 67 | textRef.html(linkset.text); 68 | 69 | crosstalk.expandLinks_(linkset, $('.images', msg), null, msg); 70 | }); 71 | 72 | // Event handlers for posting. 73 | $('#post-message').click(crosstalk.post_); 74 | $('#talkbox').keypress(function(event) { 75 | // If enter key pressed, post. 76 | if (event.which == 13 && !event.shiftKey) { 77 | crosstalk.post_(); 78 | return false; 79 | } 80 | }); 81 | 82 | // Always focus the textarea if you click anywhere in the footer 83 | $('#footer').click(function() { 84 | $('textarea', this).focus(); 85 | }) 86 | 87 | // Size the dropzone on the talkbox 88 | var talkbox = $('#talkbox'); 89 | $('#dropzone').css({ 90 | left: talkbox.left, 91 | top: talkbox.top 92 | }).width(talkbox.width()) 93 | .height(talkbox.height() - 40); 94 | 95 | // Set up file uploader. 96 | crosstalk.uploader_ = new qq.FileUploader({ 97 | element: $('#uploadButton')[0], 98 | action: "/r/upload", 99 | onComplete: function(id, file, response) { 100 | if (response && response.success) { 101 | // Remember file attachment name. 102 | talkbox.data('attachment', file); 103 | talkbox.data('attachmentId', response.id); 104 | } 105 | } 106 | }); 107 | 108 | // Set up interactive adding of terms (hashtags) to pull tweets. 109 | var inputContainer = $('#input-hashtag'); 110 | $('#add-hashtag').click(function() { 111 | inputContainer.show(); 112 | $('input', inputContainer).focus(); 113 | }); 114 | 115 | $('#input-hashtag > input').keypress(function(event) { 116 | // enter key pressed. 117 | if (event.which == 13) { 118 | // Save the tag. 119 | var input = $(this); 120 | var term = input.val(); 121 | inputContainer.before('
' + term + '
'); 122 | input.val(''); 123 | 124 | inputContainer.hide(); 125 | crosstalk.send("add-term", { room: $('#room-id').text(), text: term }, crosstalk.noop); 126 | } 127 | }); 128 | 129 | // Read current user information. 130 | crosstalk.currentUserInfo = { 131 | displayName: $('#current-user').html(), 132 | username: $('#current-user').html(), 133 | avatar: $('#current-user-avatar').html() 134 | }; 135 | 136 | // Send leave room signal when the page is unloaded. 137 | $(window).unload(function() { 138 | crosstalk.send("leave", { room: $('#room-id').text() }, crosstalk.noop); 139 | }); 140 | 141 | // Kick off timer to refresh the index. 142 | setInterval(crosstalk.refreshIndex, 5 * 60 * 1000 /* 5 minutes */); 143 | 144 | // Kick off timer to ping the server with activity. 145 | setInterval(crosstalk.ping, 30 * 1000 /* seconds */); 146 | 147 | // Join room! 148 | crosstalk.send("join", { room: $('#room-id').text() }, crosstalk.noop); 149 | }; 150 | 151 | /** 152 | * Posts a new local message and sends to server. 153 | */ 154 | crosstalk.post_ = function() { 155 | scrollToBottom(); 156 | 157 | var talkbox = $('#talkbox'); 158 | var text = talkbox.val(); 159 | if ($.trim(text) == '') { 160 | return; 161 | } 162 | talkbox.val(''); 163 | var token = $('#comet-token').html(); 164 | 165 | var attachment = talkbox.data('attachmentId'); 166 | 167 | // escape html: 168 | text = $('
').text(text).html(); 169 | 170 | var now = new Date(); 171 | var post = { 172 | author: crosstalk.currentUserInfo, 173 | postedOn: now.getHours() + ':' + now.getMinutes(), 174 | text: text 175 | }; 176 | var data = { room: $('#room-id').text(), text: text, token: token }; 177 | if (attachment) { 178 | data.attachmentId = attachment; 179 | post.attachmentId = attachment; 180 | 181 | // Clear attachments. 182 | talkbox.removeData('attachment'); 183 | talkbox.removeData('attachmentId'); 184 | } 185 | crosstalk.insertMessage_(post); 186 | 187 | // Send to the server. 188 | crosstalk.send("message", data, crosstalk.noop); 189 | }; 190 | 191 | /** 192 | * Inserts message into dom and nothing else. 193 | */ 194 | crosstalk.expandLinks_ = function(linkset, target, attachment, msg, refreshScroll) { 195 | var isImages = false; 196 | for (var i = 0; i < linkset.images.length; i ++) { 197 | var image = linkset.images[i]; 198 | 199 | if (refreshScroll) { 200 | target.append(''); 201 | } else { 202 | target.append(''); 203 | } 204 | isImages = true; 205 | } 206 | 207 | // Add any attachment that this method may have too. 208 | if (attachment) { 209 | if (refreshScroll) { 210 | target.append(''); 211 | } 212 | else { 213 | target.append(''); 214 | } 215 | isImages = true; 216 | } 217 | 218 | if (!isImages) { 219 | target.hide(); 220 | } 221 | 222 | target = $('.oembed', msg); 223 | // oEmbedify the last inserted message. 224 | $.embedly(linkset.links, { 225 | maxWidth: 400, 226 | wmode: 'transparent', 227 | elems: target 228 | }); 229 | 230 | if (refreshScroll) { 231 | scrollToBottom(); 232 | } 233 | }; 234 | 235 | crosstalk.insertMessage_ = function(post) { 236 | var refreshScroll = false; 237 | 238 | // Mobile requires different metrics to figure out whether we have to scroll to the bottom when a new message is inserted 239 | if ($('#mobileCheck').css('display') == 'none') { 240 | if ($('body').outerHeight() - $(window).height() < document.body.scrollTop + 50) { 241 | refreshScroll = true; 242 | } 243 | } 244 | else if ($('#stream').outerHeight() - $('#viewport').outerHeight() < $('#viewport').get(0).scrollTop + 50) { 245 | refreshScroll = true; 246 | } 247 | 248 | var linkset = crosstalk.linkify(post.text); 249 | var stream = $('#stream > .inner'); 250 | stream.append((post.isTweet ? '
' : '
') 251 | + '
' + post.author.username + '
' 252 | // + '' 253 | + '
' 254 | + ' ' 255 | + linkset.text.replace(/(\n\r)|\n|\r/g, '
') 256 | + '
'); 257 | 258 | var msg = $('#stream .message:last'); 259 | var target = $('.images', msg); 260 | crosstalk.expandLinks_(linkset, target, post.attachmentId, msg, refreshScroll); 261 | 262 | }; 263 | 264 | /** 265 | * Transform text links into anchor hrefs. 266 | */ 267 | crosstalk.linkify = function(text) { 268 | var pieces = text.split(/[ ]+/); 269 | var recombine = []; 270 | var links = []; 271 | var images = []; 272 | for (var i = 0; i < pieces.length; i++) { 273 | var piece = pieces[i]; 274 | if (piece.indexOf('http://') === 0) { 275 | 276 | // Images are embedded directly (without oEmbed). 277 | if (piece.match(/(\.jpg|\.png|\.gif)$/)) { 278 | images.push(piece); 279 | } else { 280 | links.push(piece); 281 | } 282 | piece = '' + piece + ''; 283 | } 284 | recombine.push(piece); 285 | recombine.push(' '); 286 | } 287 | 288 | return { 289 | text: recombine.join(''), 290 | images: images, 291 | links: links 292 | }; 293 | }; 294 | 295 | crosstalk.send = function(rpc, args, callback) { 296 | args = { data: JSON.stringify(args) }; 297 | 298 | $.ajax({ 299 | url: '/r/async/' + rpc, 300 | type: 'POST', 301 | dataType: 'json', 302 | data: args, 303 | success: callback, 304 | failure: crosstalk.noop 305 | }); 306 | }; 307 | 308 | function log(log) { 309 | $('#debug').text(log); 310 | } 311 | 312 | /*** TIMER CALLBACKS ***/ 313 | crosstalk.refreshIndex = function() { 314 | crosstalk.send("index", { 315 | room: $('#room-id').text() 316 | }, function(data) { 317 | // index data back from the server. 318 | if (data.html) { 319 | // Refresh entire activity map. 320 | $('#activity-map').html(data.html); 321 | } 322 | }); 323 | }; 324 | 325 | // Pings server telling it we're active. 326 | crosstalk.ping = function() { 327 | crosstalk.send("ping", { room: $('#room-id').text() }, crosstalk.noop); 328 | }; 329 | 330 | 331 | /*** SERVER RPC CALLBACKS ***/ 332 | crosstalk.receiveMessage_ = function(data) { 333 | crosstalk.insertMessage_(data.post); 334 | }; 335 | 336 | crosstalk.tweetArrives_ = function(data) { 337 | crosstalk.insertMessage_(data.post); 338 | }; 339 | 340 | crosstalk.joinRoom_ = function(data) { 341 | if (data.joiner.username == 'anonymous') { 342 | // Treat anonymous joiners as lurkers (they have no avatar). 343 | var countRef = $('#lurker-count'); 344 | var count = parseInt(countRef.html()); 345 | countRef.html(count + 1); 346 | } else { 347 | var id = 'av-' + data.joiner.username; 348 | 349 | // Do nothing if we already know about this contributor. 350 | if ($('#' + id).length > 0) 351 | return; 352 | $('.current-contributor-avatars') 353 | .append(''); /** temporary **/ 354 | } 355 | }; 356 | 357 | crosstalk.leaveRoom_ = function(data) { 358 | if (data.leaver.username == 'anonymous') { 359 | // Treat anonymous leavers as lurkers (they have no avatar). 360 | var countRef = $('#lurker-count'); 361 | var count = parseInt(countRef.html()); 362 | if (count) // guard against weirdness. 363 | countRef.html(count - 1); 364 | } else { 365 | // Do nothing if we already know about this contributor. 366 | $('#av-' + data.leaver.username).remove(); 367 | } 368 | }; 369 | -------------------------------------------------------------------------------- /src/main/webapp/js/main.js: -------------------------------------------------------------------------------- 1 | 2 | var TRANSLATE3D = false; 3 | 4 | if (navigator.userAgent.indexOf('iPhone') >= 0 || navigator.userAgent.indexOf('iPad') >= 0) { 5 | TRANSLATE3D = true; 6 | 7 | $('html').removeClass('notIos'); 8 | $('html').addClass('ios'); 9 | } 10 | 11 | var touch = null; 12 | 13 | $(init); 14 | 15 | function init() { 16 | if ($('#mobileCheck').css('display') != 'none') { 17 | $('#roomList > li') 18 | .removeClass('active') 19 | .removeClass('future') 20 | .addClass('inactive') 21 | 22 | var APPEAR_TIME = 1000; 23 | 24 | var roomListItems = $('#roomList > li > .anchor'); 25 | 26 | // Add click handlers to act as anchors for each card 27 | roomListItems.click(function() { 28 | window.location = $('a', this).get(0).href; 29 | 30 | return false; 31 | }); 32 | 33 | // Truncate text in room titles 34 | $('> a', roomListItems).each(function() { 35 | var CHAR_LIMIT = 22; 36 | 37 | var originalText = $(this).text(); 38 | var modifiedText = originalText; 39 | modifiedText = modifiedText.substring(0, CHAR_LIMIT); 40 | 41 | if (modifiedText.length < originalText.length) { 42 | modifiedText = modifiedText.replace(/ [^ ]*$/, ''); 43 | modifiedText += '…'; 44 | } 45 | 46 | $(this).html(modifiedText); 47 | }); 48 | 49 | // Setup switcher 50 | $('#switcher').click(switchContent) 51 | 52 | } 53 | else { 54 | var roomListItems = $('#roomList > li > .anchor'); 55 | 56 | // Add click handlers to act as anchors for each card 57 | roomListItems.click(function() { 58 | window.location = $('a', this).get(0).href; 59 | 60 | return false; 61 | }); 62 | } 63 | } 64 | 65 | function switchContent() { 66 | var ul = $(this); 67 | 68 | if ($('li:first-child', ul).hasClass('on')) { 69 | $('li', ul).removeClass('on'); 70 | $('li:last-child', ul).addClass('on'); 71 | $('#roomContent').removeClass('on'); 72 | setTimeout(initTopics, 650); 73 | } 74 | else { 75 | $('li', ul).removeClass('on'); 76 | $('li:first-child', ul).addClass('on'); 77 | $('#roomContent').addClass('on'); 78 | $('#topicContent').removeClass('on'); 79 | } 80 | 81 | return false; 82 | } 83 | 84 | var WORD_LETTERS = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ']; 85 | 86 | var topicData = [ 87 | { 88 | title: "HTML5", 89 | rank: 1 90 | }, 91 | { 92 | title: "Gmail", 93 | rank: 2 94 | }, 95 | { 96 | title: "Docs", 97 | rank: 2 98 | }, 99 | { 100 | title: "Blogger", 101 | rank: 3 102 | }, 103 | { 104 | title: "Hubs", 105 | rank: 3 106 | }, 107 | { 108 | title: "JAPAC", 109 | rank: 3 110 | }, 111 | { 112 | title: "Neon Indian", 113 | rank: 4 114 | }, 115 | { 116 | title: "Eng Summit", 117 | rank: 4 118 | }, 119 | { 120 | title: "Google Maps", 121 | rank: 4 122 | }, 123 | { 124 | title: "MacBook Pro", 125 | rank: 4 126 | }, 127 | { 128 | title: "Alan Eustace", 129 | rank: 4 130 | }, 131 | { 132 | title: "Short", 133 | rank: 4 134 | }, 135 | { 136 | title: "Text", 137 | rank: 4 138 | }, 139 | { 140 | title: "Clock", 141 | rank: 4 142 | } 143 | ]; 144 | 145 | var snippetTimer = null; 146 | var callToActionTimer = null; 147 | 148 | function initTopics() { 149 | clearTopics(); 150 | $.ajax({ 151 | url: '/r/async/topics', 152 | type: 'POST', 153 | data: {}, 154 | success: buildTopicCloud, 155 | failure: function() { 156 | alert("Error: could not contact server.") 157 | } 158 | }); 159 | setTimeout(fetchSnippet, 2000); 160 | 161 | $('#topicContent .callToAction').removeClass('off'); 162 | 163 | // Fade out the call to action after 20 seconds (so that it doesn't stay on-screen while projecting) 164 | callToActionTimer = setTimeout(function() { 165 | $('#topicContent .callToAction').addClass('off'); 166 | }, 20000); 167 | 168 | $('#topicContent').addClass('on') 169 | } 170 | 171 | function clearTopics() { 172 | clearTimeout(snippetTimer); 173 | clearTimeout(callToActionTimer); 174 | 175 | $('#topicList').html(''); 176 | $('#topicContent .snippet').remove(); 177 | } 178 | 179 | function buildTopicCloud(data) { 180 | var topicList = $('#topicList'); 181 | var positioned = []; 182 | 183 | // Loaded from server. 184 | topicData = eval('(' + data + ')'); 185 | 186 | // For each topic, position it so it doesn't overlap any others, but is close to the center 187 | for (var i = 0; i < topicData.length; i++) { 188 | var newTopic = $('
  • ' + topicData[i].title + '
  • '); 190 | 191 | newTopic.appendTo(topicList); 192 | 193 | var x = topicList.outerWidth() / 2 - newTopic.outerWidth() / 2; 194 | var y = topicList.outerHeight() / 2 - newTopic.outerHeight() / 2; 195 | 196 | 197 | var angle = Math.PI * 2 / topicData.length * i; 198 | var minSteps = null; 199 | var minX = x; 200 | var minY = y; 201 | var RETRIES = 3; 202 | 203 | // Try x = RETRIES different angles and see which allows the topic to be closest to the center 204 | for (var k = 0; k < RETRIES; k++) { 205 | newTopic 206 | .css('left', x) 207 | .css('top', y); 208 | 209 | var numSteps = 0; 210 | var newX = null; 211 | var newY = null; 212 | 213 | // While the topic is still intersecting with an existing topic 214 | while (true) { 215 | numSteps++; 216 | 217 | var intersects = false; 218 | 219 | // Check to see whether the topic is intersecting with any of the existing topics, if not: finish the while(true) loop 220 | for (var j = 0; j < positioned.length; j++) { 221 | if (elementsIntersect(positioned[j], newTopic)) { 222 | intersects = true; 223 | 224 | break; 225 | } 226 | } 227 | 228 | if (!intersects) { 229 | break; 230 | } 231 | else { 232 | if (newX == null) { 233 | newX = x; 234 | newY = y; 235 | } 236 | 237 | newX += Math.cos(angle + Math.PI * 2 / RETRIES * k) * 10; 238 | newY += Math.sin(angle + Math.PI * 2 / RETRIES * k) * 10; 239 | 240 | newTopic 241 | .css('left', newX) 242 | .css('top', newY) 243 | } 244 | } 245 | 246 | if ((minSteps == null || numSteps < minSteps) && newX != null) { 247 | minSteps = numSteps; 248 | minX = newX; 249 | minY = newY; 250 | } 251 | } 252 | 253 | newTopic 254 | .css('left', minX) 255 | .css('top', minY) 256 | 257 | positioned.push(newTopic); 258 | } 259 | } 260 | 261 | function elementsIntersect(el1, el2) { 262 | var MARGIN = 13; 263 | var el1Offset = el1.offset(); 264 | var el1x1 = el1Offset.left - MARGIN; 265 | var el1x2 = el1x1 + el1.outerWidth() + MARGIN * 2; 266 | var el1y1 = el1Offset.top - MARGIN; 267 | var el1y2 = el1y1 + el1.outerHeight() + MARGIN * 2; 268 | 269 | var el2Offset = el2.offset(); 270 | var el2x1 = el2Offset.left; 271 | var el2x2 = el2x1 + el2.outerWidth(); 272 | var el2y1 = el2Offset.top; 273 | var el2y2 = el2y1 + el2.outerHeight(); 274 | 275 | // If overlapping on x axis 276 | if (((el1x1 >= el2x1 && el1x1 <= el2x2) || (el2x1 >= el1x1 && el2x1 <= el1x2))) { 277 | // If overlapping on y axis 278 | if (((el1y1 >= el2y1 && el1y1 <= el2y2) || (el2y1 >= el1y1 && el2y1 <= el1y2))) { 279 | return true; 280 | } 281 | } 282 | 283 | return false; 284 | } 285 | 286 | function fetchSnippet() { 287 | $.ajax({ 288 | url: '/r/async/random_msg', 289 | type: 'POST', 290 | data: {}, 291 | success: showSnippets, 292 | failure: function() { 293 | alert("Error: could not contact server.") 294 | } 295 | }); 296 | } 297 | 298 | function showSnippets(data) { 299 | var topicListItems = $('#topicList li'); 300 | var item = topicListItems.eq(Math.floor(Math.random() * topicListItems.length)); 301 | 302 | topicListItems.removeClass('on'); 303 | item.addClass('on'); 304 | 305 | // Rooms are empty. Reset Timer and leave 306 | data = eval('(' + data + ')'); 307 | if (!data.text) { 308 | snippetTimer = setTimeout(fetchSnippet, 2000); 309 | return; 310 | } 311 | 312 | var randomString = data.text; 313 | var author = data.author.displayName; 314 | 315 | $('.snippet').each(function() { 316 | if ($(this).css('opacity') == '0') { 317 | $(this).remove(); 318 | } 319 | }); 320 | 321 | $('.snippet').css('opacity', 0); 322 | 323 | var newSnippet = $('
    Avatar ' + author + ' ' + randomString + '
    '); 324 | newSnippet.appendTo('#topicContent'); 325 | 326 | newSnippet 327 | .css('left', parseInt(item.css('left')) - newSnippet.outerWidth() / 2) 328 | .css('top', parseInt(item.css('top')) - newSnippet.outerHeight() - 75) 329 | 330 | var MIN_LEFT = 40; 331 | 332 | // Snippet is off left side of screen 333 | if (parseInt(newSnippet.css('left')) < MIN_LEFT) { 334 | newSnippet.css('left', parseInt(newSnippet.css('left')) + (MIN_LEFT - parseInt(newSnippet.css('left')))); 335 | } 336 | 337 | var MIN_RIGHT = 30; 338 | 339 | // Snippet is off right side of screen 340 | if (parseInt(newSnippet.css('left')) + newSnippet.outerWidth() > $(window).width() - MIN_RIGHT) { 341 | newSnippet.css('left', $(window).width() - newSnippet.outerWidth() - MIN_RIGHT); 342 | } 343 | 344 | // Snippet is off top of screen 345 | if (parseInt(newSnippet.css('top')) < 20) { 346 | newSnippet.addClass('flipped'); 347 | 348 | newSnippet.css('left', parseInt(item.css('left')) + item.outerWidth() - newSnippet.outerWidth() / 2); 349 | newSnippet.css('top', parseInt(item.css('top')) + item.outerHeight() + 75); 350 | } 351 | 352 | $('.cloudPointer1', newSnippet).css('opacity', 1); 353 | 354 | setTimeout(function() { 355 | $('.cloudPointer2', newSnippet).css('opacity', 1); 356 | }, 700) 357 | 358 | setTimeout(function() { 359 | $('.cloud', newSnippet).css('opacity', 1); 360 | }, 1400) 361 | 362 | setTimeout(function() { 363 | $('.content', newSnippet).css('opacity', 1); 364 | }, 1800) 365 | 366 | setTimeout(function() { 367 | $('.content .text span', newSnippet).each(function() { 368 | var self = this; 369 | setTimeout(function() { 370 | $(self).css('opacity', 1); 371 | }, Math.random() * 1000); 372 | }); 373 | }, 2100) 374 | 375 | snippetTimer = setTimeout(fetchSnippet, 8000); 376 | } -------------------------------------------------------------------------------- /src/main/webapp/main_mobile.css: -------------------------------------------------------------------------------- 1 | 2 | /* GENERAL ========================================= */ 3 | 4 | html { 5 | height: auto; 6 | } 7 | 8 | body { 9 | height: auto; 10 | overflow: auto; 11 | margin: 0 auto; 12 | } 13 | 14 | #mobileCheck { 15 | display: none; 16 | } 17 | 18 | #page { 19 | width: auto; 20 | height: auto; 21 | overflow: auto; 22 | } 23 | 24 | 25 | 26 | 27 | /* HEADER ========================================= */ 28 | 29 | header { 30 | position: relative; 31 | height: 41px; 32 | background-image: none; 33 | } 34 | 35 | header > h1 { 36 | float: left; 37 | width: 155px; 38 | height: 21px; 39 | margin: 10px 0 0 15px; 40 | background-image: url(/images/logo_mobile.gif); 41 | background-repeat: no-repeat; 42 | text-indent: -9999px; 43 | } 44 | 45 | header > h2 { 46 | display: none; 47 | font-family: Arial, Helvetica, sans-serif; 48 | } 49 | 50 | #newRoom { 51 | display: none; 52 | } 53 | 54 | 55 | 56 | 57 | /* CONTENT ========================================= */ 58 | 59 | #switcher { 60 | display: none; 61 | } 62 | 63 | .callToAction { 64 | position: static; 65 | width: auto; 66 | margin: 10px 10px 10px 10px; 67 | padding: 5px 10px; 68 | -webkit-border-radius: 6px; 69 | -moz-border-radius: 6px; 70 | border-radius: 6px; 71 | background-color: rgba(255,255,255,0.2); 72 | font-size: 130%; 73 | text-align: center; 74 | opacity: 0.5; 75 | line-height: 1.4; 76 | } 77 | 78 | .callToAction .click { 79 | display: none; 80 | } 81 | 82 | .callToAction .tap { 83 | display: inline; 84 | } 85 | 86 | #roomContent { 87 | display: block; 88 | padding-top: 0; 89 | } 90 | 91 | #roomList { 92 | display: block; 93 | } 94 | 95 | #roomList > li { 96 | display: block; 97 | overflow: hidden; 98 | padding: 0 10px 10px 10px; 99 | opacity: 1; 100 | } 101 | 102 | #roomList > li:first-child { 103 | padding-left: 10px; 104 | } 105 | 106 | #roomList > li:last-child { 107 | padding-right: 10px; 108 | } 109 | 110 | #roomList > li > .anchor { 111 | position: relative; 112 | top: 0; 113 | width: auto; 114 | } 115 | 116 | #roomList .anchor > a { 117 | width: 95%; 118 | overflow: hidden; 119 | font-size: 160%; 120 | font-family: Arial, Helvetica, sans-serif; 121 | } 122 | 123 | #roomList .anchor > a:before { 124 | width: 0.56em; 125 | height: 0.56em; 126 | } 127 | 128 | #roomList h2 { 129 | font-family: Arial, Helvetica, sans-serif; 130 | } 131 | 132 | #roomList time { 133 | font-family: Arial, Helvetica, sans-serif; 134 | } 135 | 136 | #roomList .contributors { 137 | margin-top: 0.7em; 138 | margin-bottom: 0; 139 | border-bottom: 0 none #ffffff; 140 | padding-bottom: 0; 141 | } 142 | 143 | #roomList .contributors ul { 144 | display: none; 145 | } 146 | 147 | #roomList .topics { 148 | display: none; 149 | } 150 | 151 | nav { 152 | display: none; 153 | } -------------------------------------------------------------------------------- /src/test/java/com/wideplay/crosstalk/data/twitter/JsonTweetParsingTest.java: -------------------------------------------------------------------------------- 1 | package com.wideplay.crosstalk.data.twitter; 2 | 3 | import com.google.gson.Gson; 4 | import com.wideplay.crosstalk.data.buzz.BuzzSearch; 5 | import org.apache.commons.io.IOUtils; 6 | import org.junit.Test; 7 | 8 | import java.io.IOException; 9 | 10 | /** 11 | * @author dhanji@gmail.com (Dhanji R. Prasanna) 12 | */ 13 | public class JsonTweetParsingTest { 14 | 15 | @Test 16 | public final void parse() throws IOException { 17 | String json = IOUtils.toString(TwitterSearch.class.getResourceAsStream("example_tweet_search.json")); 18 | 19 | TwitterSearch tweets = new Gson().fromJson(json, TwitterSearch.class); 20 | 21 | System.out.println("Parsed: " + tweets.getResults()); 22 | } 23 | 24 | 25 | @Test 26 | public final void parseBuzz() throws IOException { 27 | String json = IOUtils.toString(TwitterSearch.class.getResourceAsStream("example_buzz_@me.json")); 28 | 29 | BuzzSearch tweets = new Gson().fromJson(json, BuzzSearch.Data.class).getData(); 30 | 31 | System.out.println("Parsed: " + tweets.getItems()); 32 | } 33 | 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/test/java/com/wideplay/crosstalk/data/twitter/example_buzz_@me.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/test/java/com/wideplay/crosstalk/data/twitter/example_buzz_@me.json -------------------------------------------------------------------------------- /src/test/java/com/wideplay/crosstalk/data/twitter/example_buzz_search.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhanji/crosstalk/70a570502eefdb0afdee007074d40163dadea98f/src/test/java/com/wideplay/crosstalk/data/twitter/example_buzz_search.json -------------------------------------------------------------------------------- /src/test/java/com/wideplay/crosstalk/data/twitter/example_tweet_search.json: -------------------------------------------------------------------------------- 1 | {"results":[ 2 | { 3 | "from_user_id_str":"126005", 4 | "profile_image_url":"http://a0.twimg.com/profile_images/1207231225/kal-ginga2-sml_normal.jpg", 5 | "created_at":"Tue, 15 Feb 2011 11:34:32 +0000", 6 | "from_user":"kalena", 7 | "id_str":"37474839820374016", 8 | "metadata":{ 9 | "result_type":"recent" 10 | }, 11 | "to_user_id":null, 12 | "text":"BIG day tomorrow. Pilgrimage to Te Papa followed by a #webstock workshop with @hotdogsladies. Or as we like to call him, Uncle Merlin.", 13 | "id":37474839820374016, 14 | "from_user_id":126005, 15 | "geo":null, 16 | "iso_language_code":"en", 17 | "to_user_id_str":null, 18 | "source":"<a href="http://www.tweetdeck.com" rel="nofollow">TweetDeck</a>" 19 | }, 20 | { 21 | "from_user_id_str":"6447915", 22 | "profile_image_url":"http://a2.twimg.com/profile_images/998695106/kodie_normal.jpg", 23 | "created_at":"Tue, 15 Feb 2011 11:21:39 +0000", 24 | "from_user":"kodiewix", 25 | "id_str":"37471598613102592", 26 | "metadata":{ 27 | "result_type":"recent" 28 | }, 29 | "to_user_id":null, 30 | "text":"RT @wellingtonista: @themorgan this is what people are doing tomorrow night before #webstock http://twtvite.com/webwarm11", 31 | "id":37471598613102592, 32 | "from_user_id":6447915, 33 | "geo":null, 34 | "iso_language_code":"en", 35 | "to_user_id_str":null, 36 | "source":"<a href="http://www.hootsuite.com" rel="nofollow">HootSuite</a>" 37 | }, 38 | { 39 | "from_user_id_str":"23237", 40 | "profile_image_url":"http://a3.twimg.com/profile_images/852042378/shadowfoot_normal.png", 41 | "created_at":"Tue, 15 Feb 2011 10:15:46 +0000", 42 | "from_user":"Shadowfoot", 43 | "id_str":"37455016830701568", 44 | "metadata":{ 45 | "result_type":"recent" 46 | }, 47 | "to_user_id":1131468, 48 | "text":"@mshel You can sleep next week, after #webstock", 49 | "id":37455016830701568, 50 | "from_user_id":23237, 51 | "to_user":"mshel", 52 | "geo":null, 53 | "iso_language_code":"en", 54 | "to_user_id_str":"1131468", 55 | "source":"<a href="http://www.tweetdeck.com" rel="nofollow">TweetDeck</a>" 56 | }, 57 | { 58 | "from_user_id_str":"1131468", 59 | "profile_image_url":"http://a1.twimg.com/profile_images/1244978501/photo_5a_normal.JPG", 60 | "created_at":"Tue, 15 Feb 2011 10:15:17 +0000", 61 | "from_user":"mshel", 62 | "id_str":"37454893400727552", 63 | "metadata":{ 64 | "result_type":"recent" 65 | }, 66 | "to_user_id":null, 67 | "text":"Having a crazy awesome time at #webstock - SO tired but still, looking forward to tomorrow! :)", 68 | "id":37454893400727552, 69 | "from_user_id":1131468, 70 | "geo":null, 71 | "iso_language_code":"en", 72 | "to_user_id_str":null, 73 | "source":"<a href="http://twitter.com/">web</a>" 74 | }, 75 | { 76 | "from_user_id_str":"23237", 77 | "profile_image_url":"http://a3.twimg.com/profile_images/852042378/shadowfoot_normal.png", 78 | "created_at":"Tue, 15 Feb 2011 10:13:16 +0000", 79 | "from_user":"Shadowfoot", 80 | "id_str":"37454385898323968", 81 | "metadata":{ 82 | "result_type":"recent" 83 | }, 84 | "to_user_id":19072, 85 | "text":"@cperfetti I really enjoyed your Usability Bootcamp workshop at #Webstock today. thank you.", 86 | "id":37454385898323968, 87 | "from_user_id":23237, 88 | "to_user":"cperfetti", 89 | "geo":null, 90 | "iso_language_code":"en", 91 | "to_user_id_str":"19072", 92 | "source":"<a href="http://twitter.com/">web</a>" 93 | }, 94 | { 95 | "from_user_id_str":"60852100", 96 | "profile_image_url":"http://a0.twimg.com/profile_images/1234081793/logo-01_normal.png", 97 | "created_at":"Tue, 15 Feb 2011 10:00:39 +0000", 98 | "from_user":"rushdigital", 99 | "id_str":"37451211942658049", 100 | "metadata":{ 101 | "result_type":"recent" 102 | }, 103 | "to_user_id":26598, 104 | "text":"@majicDave awsm! will flick an eml with my deets, nt sure if u knw bt @KeriHenare is in welly 2mrw 4 #webstock - hes a massiv #chopper fan", 105 | "id":37451211942658049, 106 | "from_user_id":60852100, 107 | "to_user":"majicDave", 108 | "geo":null, 109 | "iso_language_code":"en", 110 | "to_user_id_str":"26598", 111 | "source":"<a href="http://twitter.com/">web</a>" 112 | }, 113 | { 114 | "from_user_id_str":"95852", 115 | "profile_image_url":"http://a1.twimg.com/profile_images/1126801420/bestbagel-icon_normal.jpg", 116 | "created_at":"Tue, 15 Feb 2011 09:51:20 +0000", 117 | "from_user":"Ycnt_ibdonlyjen", 118 | "id_str":"37448865821102081", 119 | "metadata":{ 120 | "result_type":"recent" 121 | }, 122 | "to_user_id":null, 123 | "text":"i'm signed up to @wsbingo, going to #webwarm11 tomorrow nite & looking forward to @globalmoxie workshop tomorrow. but now sleep #webstock", 124 | "id":37448865821102081, 125 | "from_user_id":95852, 126 | "geo":null, 127 | "iso_language_code":"en", 128 | "to_user_id_str":null, 129 | "source":"<a href="http://twitter.com/">web</a>" 130 | }, 131 | { 132 | "from_user_id_str":"26735", 133 | "profile_image_url":"http://a0.twimg.com/profile_images/1207093267/nyeme_normal.jpg", 134 | "created_at":"Tue, 15 Feb 2011 09:49:34 +0000", 135 | "from_user":"johubris", 136 | "id_str":"37448422973902848", 137 | "metadata":{ 138 | "result_type":"recent" 139 | }, 140 | "to_user_id":null, 141 | "text":"I'm at 29,192 tweets right now, I bet I'll crack 30,000 by the time #webstock is over", 142 | "id":37448422973902848, 143 | "from_user_id":26735, 144 | "geo":null, 145 | "iso_language_code":"en", 146 | "to_user_id_str":null, 147 | "source":"<a href="http://www.hootsuite.com" rel="nofollow">HootSuite</a>" 148 | }, 149 | { 150 | "from_user_id_str":"2633373", 151 | "profile_image_url":"http://a0.twimg.com/profile_images/1224493772/b_normal.png", 152 | "created_at":"Tue, 15 Feb 2011 09:49:16 +0000", 153 | "from_user":"benpeyer", 154 | "id_str":"37448349716185088", 155 | "metadata":{ 156 | "result_type":"recent" 157 | }, 158 | "to_user_id":null, 159 | "text":"Workshop with @jasonsantamaria was just great. Thank you! \nSo much more to learn. That's exciting and also a bit frightening... #webstock", 160 | "id":37448349716185088, 161 | "from_user_id":2633373, 162 | "geo":null, 163 | "iso_language_code":"en", 164 | "to_user_id_str":null, 165 | "source":"<a href="http://itunes.apple.com/us/app/twitter/id409789998?mt=12" rel="nofollow">Twitter for Mac</a>" 166 | }, 167 | { 168 | "from_user_id_str":"76550538", 169 | "profile_image_url":"http://a2.twimg.com/profile_images/850805265/LinNahCaricature_normal.jpg", 170 | "created_at":"Tue, 15 Feb 2011 09:45:34 +0000", 171 | "from_user":"lin_nah", 172 | "id_str":"37447416399671296", 173 | "metadata":{ 174 | "result_type":"recent" 175 | }, 176 | "to_user_id":null, 177 | "text":"butlers choc isn't far from #webstock so anyone attending webstock can take advantage of deal too (if have printer to print voucher)", 178 | "id":37447416399671296, 179 | "from_user_id":76550538, 180 | "geo":null, 181 | "iso_language_code":"en", 182 | "to_user_id_str":null, 183 | "source":"<a href="http://www.tweetdeck.com" rel="nofollow">TweetDeck</a>" 184 | }, 185 | { 186 | "from_user_id_str":"33239", 187 | "profile_image_url":"http://a1.twimg.com/profile_images/1022712372/me_suspicious_large_normal.jpg", 188 | "created_at":"Tue, 15 Feb 2011 09:40:14 +0000", 189 | "from_user":"gianouts", 190 | "id_str":"37446074822041600", 191 | "metadata":{ 192 | "result_type":"recent" 193 | }, 194 | "to_user_id":35305673, 195 | "text":"@simonemccallum Excellent. #webstock is the place to be :)", 196 | "id":37446074822041600, 197 | "from_user_id":33239, 198 | "to_user":"simonemccallum", 199 | "geo":null, 200 | "iso_language_code":"en", 201 | "to_user_id_str":"35305673", 202 | "source":"<a href="http://www.tweetdeck.com" rel="nofollow">TweetDeck</a>" 203 | }, 204 | { 205 | "from_user_id_str":"3381607", 206 | "profile_image_url":"http://a3.twimg.com/profile_images/1124210252/homepage_me_normal.jpg", 207 | "created_at":"Tue, 15 Feb 2011 09:31:29 +0000", 208 | "from_user":"therussdotcom", 209 | "id_str":"37443874230116353", 210 | "metadata":{ 211 | "result_type":"recent" 212 | }, 213 | "to_user_id":null, 214 | "text":"RT: @wonkeydonkey42 #webstock, speaker line up not as inspiring as past years - agreed. Seeing Steve Souders tomorrow at a workshop though..", 215 | "id":37443874230116353, 216 | "from_user_id":3381607, 217 | "geo":null, 218 | "iso_language_code":"en", 219 | "to_user_id_str":null, 220 | "source":"<a href="http://www.echofon.com/" rel="nofollow">Echofon</a>" 221 | }, 222 | { 223 | "from_user_id_str":"425868", 224 | "profile_image_url":"http://a1.twimg.com/profile_images/1204003906/nboehm_studio_250_normal.jpg", 225 | "created_at":"Tue, 15 Feb 2011 09:29:41 +0000", 226 | "from_user":"NathanaelB", 227 | "id_str":"37443418800001025", 228 | "metadata":{ 229 | "result_type":"recent" 230 | }, 231 | "to_user_id":null, 232 | "text":"... and I can't afford $120 for an Apple keyboard. Guess I'll just take notes and write blog posts later :) #webstock", 233 | "id":37443418800001025, 234 | "from_user_id":425868, 235 | "geo":{ 236 | "type":"Point", 237 | "coordinates":[-43.525,172.6243] 238 | }, 239 | "iso_language_code":"en", 240 | "to_user_id_str":null, 241 | "source":"<a href="http://stone.com/Twittelator" rel="nofollow">Twittelator</a>" 242 | }, 243 | { 244 | "from_user_id_str":"35305673", 245 | "profile_image_url":"http://a3.twimg.com/profile_images/1166076361/twitter_photo_nov2_normal.jpg", 246 | "created_at":"Tue, 15 Feb 2011 09:29:04 +0000", 247 | "from_user":"simonemccallum", 248 | "id_str":"37443264441352192", 249 | "metadata":{ 250 | "result_type":"recent" 251 | }, 252 | "to_user_id":33239, 253 | "text":"@gianouts Might see you on Thurs and Fri #webstock", 254 | "id":37443264441352192, 255 | "from_user_id":35305673, 256 | "to_user":"gianouts", 257 | "geo":null, 258 | "iso_language_code":"en", 259 | "to_user_id_str":"33239", 260 | "source":"<a href="http://www.tweetdeck.com" rel="nofollow">TweetDeck</a>" 261 | }, 262 | { 263 | "from_user_id_str":"425868", 264 | "profile_image_url":"http://a1.twimg.com/profile_images/1204003906/nboehm_studio_250_normal.jpg", 265 | "created_at":"Tue, 15 Feb 2011 09:28:23 +0000", 266 | "from_user":"NathanaelB", 267 | "id_str":"37443092801921024", 268 | "metadata":{ 269 | "result_type":"recent" 270 | }, 271 | "to_user_id":null, 272 | "text":"Hmm. Guess my Eee PC isn't coming to #webstock. Can't get 2degrees 3G working on it, although it's always been unreliable with GPRS modems.", 273 | "id":37443092801921024, 274 | "from_user_id":425868, 275 | "geo":{ 276 | "type":"Point", 277 | "coordinates":[-43.525,172.6243] 278 | }, 279 | "iso_language_code":"en", 280 | "to_user_id_str":null, 281 | "source":"<a href="http://stone.com/Twittelator" rel="nofollow">Twittelator</a>" 282 | } 283 | ],"max_id":37474839820374016,"since_id":0,"refresh_url":"?since_id=37474839820374016&q=%23webstock","next_page":"?page=2&max_id=37474839820374016&q=%23webstock","results_per_page":15,"page":1,"completed_in":0.017795,"since_id_str":"0","max_id_str":"37474839820374016","query":"%23webstock"} 284 | -------------------------------------------------------------------------------- /src/test/java/com/wideplay/crosstalk/data/twitter/example_twitter_creds.json: -------------------------------------------------------------------------------- 1 | {"following":false,"profile_link_color":"009999","url":"http:\/\/www.wideplay.com","verified":false,"profile_sidebar_border_color":"eeeeee","description":"Senior Custodial Engineer at Google","status":{ 2 | "truncated":false, 3 | "text":"RT @crazybob: \"Dependency Injection can be dangerous for your career.\" http:\/\/is.gd\/We9gIa #stackoverflow", 4 | "favorited":false, 5 | "geo":null, 6 | "retweet_count":36, 7 | "in_reply_to_screen_name":null, 8 | "in_reply_to_user_id":null, 9 | "source":"\u003Ca href=\"http:\/\/itunes.apple.com\/us\/app\/twitter\/id409789998?mt=12\" rel=\"nofollow\"\u003ETwitter for Mac\u003C\/a\u003E", 10 | "in_reply_to_status_id_str":null, 11 | "created_at":"Fri Feb 11 23:10:54 +0000 2011", 12 | "contributors":null, 13 | "place":null, 14 | "coordinates":null, 15 | "retweeted":false, 16 | "in_reply_to_user_id_str":null, 17 | "retweeted_status":{ 18 | "truncated":false, 19 | "text":"\"Dependency Injection can be dangerous for your career.\" http:\/\/is.gd\/We9gIa #stackoverflow", 20 | "favorited":false, 21 | "geo":null, 22 | "retweet_count":36, 23 | "in_reply_to_screen_name":null, 24 | "in_reply_to_user_id":null, 25 | "source":"web", 26 | "in_reply_to_status_id_str":null, 27 | "created_at":"Fri Feb 11 18:33:23 +0000 2011", 28 | "contributors":null, 29 | "place":null, 30 | "coordinates":null, 31 | "retweeted":false, 32 | "in_reply_to_user_id_str":null, 33 | "id_str":"36130695424393216", 34 | "id":36130695424393216, 35 | "in_reply_to_status_id":null 36 | }, 37 | "id_str":"36200533437972480", 38 | "id":36200533437972480, 39 | "in_reply_to_status_id":null 40 | }, "followers_count":1202,"location":"Sydney, Australia","follow_request_sent":false,"profile_use_background_image":true,"profile_image_url":"http:\/\/a3.twimg.com\/profile_images\/53414948\/dj_sp_normal.jpg","show_all_inline_media":false,"contributors_enabled":false,"notifications":false,"friends_count":186,"profile_background_color":"131516","geo_enabled":true,"profile_background_image_url":"http:\/\/a2.twimg.com\/a\/1296081712\/images\/themes\/theme14\/bg.gif","created_at":"Mon Apr 28 01:03:24 +0000 2008","screen_name":"dhanji","listed_count":134,"time_zone":"Sydney","profile_text_color":"333333","protected":false,"lang":"en","statuses_count":5901,"favourites_count":47,"profile_sidebar_fill_color":"efefef","name":"Dhanji R. Prasanna","id_str":"14563623","is_translator":false,"profile_background_tile":true,"id":14563623,"utc_offset":36000} 41 | --------------------------------------------------------------------------------